postgresai 0.16.0-rc.4 → 0.16.0
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 +154 -0
- package/dist/bin/postgres-ai.js +2911 -255
- package/package.json +12 -3
- package/schemas/A002.schema.json +63 -0
- package/schemas/A003.schema.json +73 -0
- package/schemas/A004.schema.json +81 -0
- package/schemas/A007.schema.json +71 -0
- package/schemas/A013.schema.json +61 -0
- package/schemas/D001.schema.json +71 -0
- package/schemas/D004.schema.json +136 -0
- package/schemas/F001.schema.json +73 -0
- package/schemas/F002.schema.json +108 -0
- package/schemas/F003.schema.json +138 -0
- package/schemas/F004.schema.json +125 -0
- package/schemas/F005.schema.json +131 -0
- package/schemas/F009.schema.json +155 -0
- package/schemas/G001.schema.json +135 -0
- package/schemas/G003.schema.json +90 -0
- package/schemas/H001.schema.json +141 -0
- package/schemas/H002.schema.json +129 -0
- package/schemas/H004.schema.json +128 -0
- package/schemas/I001.schema.json +149 -0
- package/schemas/K001.schema.json +161 -0
- package/schemas/K003.schema.json +163 -0
- package/schemas/K004.schema.json +110 -0
- package/schemas/K005.schema.json +110 -0
- package/schemas/K006.schema.json +110 -0
- package/schemas/K007.schema.json +110 -0
- package/schemas/K008.schema.json +110 -0
- package/schemas/M001.schema.json +119 -0
- package/schemas/M002.schema.json +110 -0
- package/schemas/M003.schema.json +128 -0
- package/schemas/N001.schema.json +161 -0
- package/schemas/query.schema.json +62 -0
- package/CHANGELOG.md +0 -11
- package/bin/postgres-ai.ts +0 -5578
- package/bun.lock +0 -258
- package/bunfig.toml +0 -20
- package/lib/aas-onboard.ts +0 -251
- package/lib/auth-server.ts +0 -285
- package/lib/checkup-api.ts +0 -526
- package/lib/checkup-dictionary.ts +0 -103
- package/lib/checkup-summary.ts +0 -338
- package/lib/checkup.ts +0 -2261
- package/lib/config.ts +0 -171
- package/lib/init.ts +0 -1152
- package/lib/instances.ts +0 -245
- package/lib/issues.ts +0 -1060
- package/lib/mcp-server.ts +0 -667
- package/lib/metrics-loader.ts +0 -134
- package/lib/pkce.ts +0 -79
- package/lib/reports.ts +0 -373
- package/lib/storage.ts +0 -367
- package/lib/supabase.ts +0 -826
- package/lib/util.ts +0 -134
- package/packages/postgres-ai/README.md +0 -26
- package/packages/postgres-ai/bin/postgres-ai.js +0 -27
- package/packages/postgres-ai/package.json +0 -27
- package/scripts/embed-checkup-dictionary.ts +0 -115
- package/scripts/embed-metrics.ts +0 -160
- package/scripts/generate-release-notes.ts +0 -668
- package/test/PERMISSION_CHECK_TEST_SUMMARY.md +0 -139
- package/test/aas-onboard.test.ts +0 -301
- package/test/auth.test.ts +0 -287
- package/test/checkup.integration.test.ts +0 -413
- package/test/checkup.test.ts +0 -3626
- package/test/compose-cmd.test.ts +0 -120
- package/test/config-consistency.test.ts +0 -352
- package/test/init.integration.test.ts +0 -438
- package/test/init.test.ts +0 -1816
- package/test/issues.cli.test.ts +0 -1162
- package/test/issues.test.ts +0 -456
- package/test/mcp-server.test.ts +0 -2530
- package/test/monitoring.test.ts +0 -746
- package/test/permission-check-sql.test.ts +0 -116
- package/test/reports.cli.test.ts +0 -793
- package/test/reports.test.ts +0 -977
- package/test/schema-validation.test.ts +0 -231
- package/test/storage.test.ts +0 -935
- package/test/supabase.test.ts +0 -709
- package/test/targets-add-config.test.ts +0 -28
- package/test/test-utils.ts +0 -190
- package/test/upgrade.test.ts +0 -1056
- package/test/util.test.ts +0 -44
- package/tsconfig.json +0 -20
package/lib/checkup.ts
DELETED
|
@@ -1,2261 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Express Checkup Module
|
|
3
|
-
* ======================
|
|
4
|
-
* Generates JSON health check reports directly from PostgreSQL without Prometheus.
|
|
5
|
-
*
|
|
6
|
-
* ARCHITECTURAL DECISIONS
|
|
7
|
-
* -----------------------
|
|
8
|
-
*
|
|
9
|
-
* 1. SINGLE SOURCE OF TRUTH FOR SQL QUERIES
|
|
10
|
-
* Complex metrics (index health, settings, db_stats) are loaded from
|
|
11
|
-
* config/pgwatch-prometheus/metrics.yml via getMetricSql() from metrics-loader.ts.
|
|
12
|
-
*
|
|
13
|
-
* Simple queries (version, database list, connection states, uptime) use
|
|
14
|
-
* inline SQL as they're trivial and CLI-specific.
|
|
15
|
-
*
|
|
16
|
-
* 2. JSON SCHEMA COMPLIANCE
|
|
17
|
-
* All generated reports MUST comply with JSON schemas in reporter/schemas/.
|
|
18
|
-
* These schemas define the expected format for both:
|
|
19
|
-
* - Full-fledged monitoring reporter output
|
|
20
|
-
* - Express checkup output
|
|
21
|
-
*
|
|
22
|
-
* Before adding or modifying a report, verify the corresponding schema exists
|
|
23
|
-
* and ensure the output matches. Run schema validation tests to confirm.
|
|
24
|
-
*
|
|
25
|
-
* 3. ERROR HANDLING STRATEGY
|
|
26
|
-
* Functions follow two patterns based on criticality:
|
|
27
|
-
*
|
|
28
|
-
* PROPAGATING (throws on error):
|
|
29
|
-
* - Core data functions: getPostgresVersion, getSettings, getAlteredSettings,
|
|
30
|
-
* getDatabaseSizes, getInvalidIndexes, getUnusedIndexes, getRedundantIndexes
|
|
31
|
-
* - If these fail, the entire report should fail (data is required)
|
|
32
|
-
* - Callers should handle errors at the report generation level
|
|
33
|
-
*
|
|
34
|
-
* GRACEFUL DEGRADATION (catches errors, includes error in output):
|
|
35
|
-
* - Optional/supplementary queries: pg_stat_statements, pg_stat_kcache checks,
|
|
36
|
-
* memory calculations, postmaster startup time
|
|
37
|
-
* - These are nice-to-have; missing data shouldn't fail the whole report
|
|
38
|
-
* - Errors are logged and included in report output for visibility
|
|
39
|
-
*
|
|
40
|
-
* ADDING NEW REPORTS
|
|
41
|
-
* ------------------
|
|
42
|
-
* 1. Add/verify the metric exists in config/pgwatch-prometheus/metrics.yml
|
|
43
|
-
* 2. Add the metric name mapping to METRIC_NAMES in metrics-loader.ts
|
|
44
|
-
* 3. Verify JSON schema exists in reporter/schemas/{CHECK_ID}.schema.json
|
|
45
|
-
* 4. Implement the generator function using getMetricSql()
|
|
46
|
-
* 5. Add schema validation test in test/schema-validation.test.ts
|
|
47
|
-
*/
|
|
48
|
-
|
|
49
|
-
import { Client } from "pg";
|
|
50
|
-
import * as fs from "fs";
|
|
51
|
-
import * as path from "path";
|
|
52
|
-
import * as pkg from "../package.json";
|
|
53
|
-
import { getMetricSql, transformMetricRow, METRIC_NAMES } from "./metrics-loader";
|
|
54
|
-
import { buildCheckInfoMap } from "./checkup-dictionary";
|
|
55
|
-
|
|
56
|
-
// Time constants
|
|
57
|
-
const SECONDS_PER_DAY = 86400;
|
|
58
|
-
const SECONDS_PER_HOUR = 3600;
|
|
59
|
-
const SECONDS_PER_MINUTE = 60;
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Convert various boolean representations to boolean.
|
|
63
|
-
* PostgreSQL returns booleans as true/false, 1/0, 't'/'f', or 'true'/'false'
|
|
64
|
-
* depending on context (query result, JDBC driver, etc.).
|
|
65
|
-
*/
|
|
66
|
-
function toBool(val: unknown): boolean {
|
|
67
|
-
return val === true || val === 1 || val === "t" || val === "true";
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* PostgreSQL version information
|
|
72
|
-
*/
|
|
73
|
-
export interface PostgresVersion {
|
|
74
|
-
version: string;
|
|
75
|
-
server_version_num: string;
|
|
76
|
-
server_major_ver: string;
|
|
77
|
-
server_minor_ver: string;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Setting information from pg_settings
|
|
82
|
-
*/
|
|
83
|
-
export interface SettingInfo {
|
|
84
|
-
setting: string;
|
|
85
|
-
unit: string;
|
|
86
|
-
category: string;
|
|
87
|
-
context: string;
|
|
88
|
-
vartype: string;
|
|
89
|
-
pretty_value: string;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Altered setting (A007) - subset of SettingInfo
|
|
94
|
-
*/
|
|
95
|
-
export interface AlteredSetting {
|
|
96
|
-
value: string;
|
|
97
|
-
unit: string;
|
|
98
|
-
category: string;
|
|
99
|
-
pretty_value: string;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Cluster metric (A004)
|
|
104
|
-
*/
|
|
105
|
-
export interface ClusterMetric {
|
|
106
|
-
value: string;
|
|
107
|
-
unit: string;
|
|
108
|
-
description: string;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Invalid index entry (H001) - matches H001.schema.json invalidIndex
|
|
113
|
-
*
|
|
114
|
-
* Decision tree for remediation recommendations:
|
|
115
|
-
* 1. has_valid_duplicate=true → DROP (valid duplicate exists, safe to remove)
|
|
116
|
-
* 2. is_pk=true or is_unique=true → RECREATE (backs a constraint, must restore)
|
|
117
|
-
* 3. table_row_estimate < 10000 → RECREATE (small table, quick rebuild)
|
|
118
|
-
* 4. Otherwise → UNCERTAIN (needs manual analysis of query plans)
|
|
119
|
-
*/
|
|
120
|
-
export interface InvalidIndex {
|
|
121
|
-
schema_name: string;
|
|
122
|
-
table_name: string;
|
|
123
|
-
index_name: string;
|
|
124
|
-
relation_name: string;
|
|
125
|
-
index_size_bytes: number;
|
|
126
|
-
index_size_pretty: string;
|
|
127
|
-
/** Full CREATE INDEX statement from pg_get_indexdef() - useful for DROP/RECREATE migrations */
|
|
128
|
-
index_definition: string;
|
|
129
|
-
supports_fk: boolean;
|
|
130
|
-
/** True if this index backs a PRIMARY KEY constraint */
|
|
131
|
-
is_pk: boolean;
|
|
132
|
-
/** True if this is a UNIQUE index (includes PK indexes) */
|
|
133
|
-
is_unique: boolean;
|
|
134
|
-
/** Name of the constraint this index backs, or null if none */
|
|
135
|
-
constraint_name: string | null;
|
|
136
|
-
/** Estimated row count of the table from pg_class.reltuples */
|
|
137
|
-
table_row_estimate: number;
|
|
138
|
-
/** True if there is a valid index on the same column(s) */
|
|
139
|
-
has_valid_duplicate: boolean;
|
|
140
|
-
/** Name of the valid duplicate index if one exists */
|
|
141
|
-
valid_duplicate_name: string | null;
|
|
142
|
-
/** Full CREATE INDEX statement of the valid duplicate index */
|
|
143
|
-
valid_duplicate_definition: string | null;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** Recommendation for handling an invalid index */
|
|
147
|
-
export type InvalidIndexRecommendation = "DROP" | "RECREATE" | "UNCERTAIN";
|
|
148
|
-
|
|
149
|
-
/** Threshold for considering a table "small" (quick to rebuild) */
|
|
150
|
-
const SMALL_TABLE_ROW_THRESHOLD = 10000;
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Compute remediation recommendation for an invalid index using decision tree.
|
|
154
|
-
*
|
|
155
|
-
* Decision tree logic:
|
|
156
|
-
* 1. If has_valid_duplicate is true → DROP (valid duplicate exists, safe to remove)
|
|
157
|
-
* 2. If is_pk or is_unique is true → RECREATE (backs a constraint, must restore)
|
|
158
|
-
* 3. If table_row_estimate < 10000 → RECREATE (small table, quick rebuild)
|
|
159
|
-
* 4. Otherwise → UNCERTAIN (needs manual analysis of query plans)
|
|
160
|
-
*
|
|
161
|
-
* @param index - Invalid index with observation data
|
|
162
|
-
* @returns Recommendation: "DROP", "RECREATE", or "UNCERTAIN"
|
|
163
|
-
*/
|
|
164
|
-
export function getInvalidIndexRecommendation(index: InvalidIndex): InvalidIndexRecommendation {
|
|
165
|
-
// 1. Valid duplicate exists - safe to drop
|
|
166
|
-
if (index.has_valid_duplicate) {
|
|
167
|
-
return "DROP";
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// 2. Backs a constraint - must recreate
|
|
171
|
-
if (index.is_pk || index.is_unique) {
|
|
172
|
-
return "RECREATE";
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// 3. Small table - quick to recreate
|
|
176
|
-
if (index.table_row_estimate < SMALL_TABLE_ROW_THRESHOLD) {
|
|
177
|
-
return "RECREATE";
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// 4. Large table without clear path - needs manual analysis
|
|
181
|
-
return "UNCERTAIN";
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Unused index entry (H002) - matches H002.schema.json unusedIndex
|
|
186
|
-
*/
|
|
187
|
-
export interface UnusedIndex {
|
|
188
|
-
schema_name: string;
|
|
189
|
-
table_name: string;
|
|
190
|
-
index_name: string;
|
|
191
|
-
index_definition: string;
|
|
192
|
-
reason: string;
|
|
193
|
-
idx_scan: number;
|
|
194
|
-
index_size_bytes: number;
|
|
195
|
-
idx_is_btree: boolean;
|
|
196
|
-
supports_fk: boolean;
|
|
197
|
-
index_size_pretty: string;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Stats reset info for H002 - matches H002.schema.json statsReset
|
|
202
|
-
*/
|
|
203
|
-
export interface StatsReset {
|
|
204
|
-
stats_reset_epoch: number | null;
|
|
205
|
-
stats_reset_time: string | null;
|
|
206
|
-
days_since_reset: number | null;
|
|
207
|
-
postmaster_startup_epoch: number | null;
|
|
208
|
-
postmaster_startup_time: string | null;
|
|
209
|
-
/** Set when postmaster startup time query fails - indicates data availability issue */
|
|
210
|
-
postmaster_startup_error?: string;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Redundant index entry (H004) - matches H004.schema.json redundantIndex
|
|
215
|
-
*/
|
|
216
|
-
/**
|
|
217
|
-
* Index that makes another index redundant.
|
|
218
|
-
* Used in redundant_to array to show which indexes this one is redundant to.
|
|
219
|
-
*/
|
|
220
|
-
export interface RedundantToIndex {
|
|
221
|
-
index_name: string;
|
|
222
|
-
index_definition: string;
|
|
223
|
-
index_size_bytes: number;
|
|
224
|
-
index_size_pretty: string;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
export interface RedundantIndex {
|
|
228
|
-
schema_name: string;
|
|
229
|
-
table_name: string;
|
|
230
|
-
index_name: string;
|
|
231
|
-
relation_name: string;
|
|
232
|
-
access_method: string;
|
|
233
|
-
reason: string;
|
|
234
|
-
index_size_bytes: number;
|
|
235
|
-
table_size_bytes: number;
|
|
236
|
-
index_usage: number;
|
|
237
|
-
supports_fk: boolean;
|
|
238
|
-
index_definition: string;
|
|
239
|
-
index_size_pretty: string;
|
|
240
|
-
table_size_pretty: string;
|
|
241
|
-
redundant_to: RedundantToIndex[];
|
|
242
|
-
/** Set when redundant_to_json parsing fails - indicates data quality issue */
|
|
243
|
-
redundant_to_parse_error?: string;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Dead tuples table entry (F003) - matches F003.schema.json deadTuplesTable
|
|
248
|
-
*
|
|
249
|
-
* Sourced from pg_stat_user_tables live counters (n_dead_tup / n_live_tup),
|
|
250
|
-
* so dead tuples that have never been vacuumed are visible - unlike the
|
|
251
|
-
* statistical bloat estimators (F004/F005), which miss them entirely.
|
|
252
|
-
*/
|
|
253
|
-
export interface DeadTuplesTable {
|
|
254
|
-
schema_name: string;
|
|
255
|
-
table_name: string;
|
|
256
|
-
n_live_tup: number;
|
|
257
|
-
n_dead_tup: number;
|
|
258
|
-
/** Dead tuples as percentage of all tuples: n_dead_tup / (n_live_tup + n_dead_tup) * 100 */
|
|
259
|
-
dead_pct: number;
|
|
260
|
-
last_autovacuum: string | null;
|
|
261
|
-
/** Epoch seconds of the last autovacuum; 0 = never */
|
|
262
|
-
last_autovacuum_epoch: number;
|
|
263
|
-
last_vacuum: string | null;
|
|
264
|
-
/** Epoch seconds of the last manual vacuum; 0 = never */
|
|
265
|
-
last_vacuum_epoch: number;
|
|
266
|
-
autovacuum_count: number;
|
|
267
|
-
vacuum_count: number;
|
|
268
|
-
/** True when autovacuum is disabled per-table via reloptions (autovacuum_enabled=off/false/0/...) */
|
|
269
|
-
autovacuum_disabled: boolean;
|
|
270
|
-
table_size_bytes: number;
|
|
271
|
-
table_size_pretty: string;
|
|
272
|
-
/** True when BOTH F003_DEAD_TUPLES_MIN and F003_DEAD_PCT_MIN thresholds are exceeded */
|
|
273
|
-
exceeds_dead_tuple_thresholds: boolean;
|
|
274
|
-
/** True when autovacuum is disabled per-table on a non-tiny table (>= F003_AUTOVACUUM_DISABLED_MIN_ROWS tuples) */
|
|
275
|
-
autovacuum_disabled_flagged: boolean;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/**
|
|
279
|
-
* F003 thresholds.
|
|
280
|
-
*
|
|
281
|
-
* A table's dead-tuple accumulation is flagged only when it is high in BOTH
|
|
282
|
-
* absolute and relative terms:
|
|
283
|
-
* - F003_DEAD_TUPLES_MIN keeps small/noisy tables out (100k dead tuples is
|
|
284
|
-
* real work for vacuum regardless of table size);
|
|
285
|
-
* - F003_DEAD_PCT_MIN = 20 mirrors the default autovacuum_vacuum_scale_factor
|
|
286
|
-
* of 0.2: with default settings autovacuum should have fired well before a
|
|
287
|
-
* table is 20% dead, so reaching this level in a snapshot is an unambiguous
|
|
288
|
-
* signal that vacuum is not keeping up (lagging, blocked, or disabled).
|
|
289
|
-
*
|
|
290
|
-
* Per-table disabled autovacuum is a classic footgun and is always flagged on
|
|
291
|
-
* non-tiny tables (>= F003_AUTOVACUUM_DISABLED_MIN_ROWS total tuples; same
|
|
292
|
-
* 10k-row "non-tiny" cutoff the classic postgres-checkup F003 uses).
|
|
293
|
-
*/
|
|
294
|
-
export const F003_DEAD_TUPLES_MIN = 100_000;
|
|
295
|
-
export const F003_DEAD_PCT_MIN = 20;
|
|
296
|
-
export const F003_AUTOVACUUM_DISABLED_MIN_ROWS = 10_000;
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* I/O statistics by backend type (I001) - matches I001.schema.json backendIOStats
|
|
300
|
-
*/
|
|
301
|
-
export interface BackendIOStats {
|
|
302
|
-
backend_type: string;
|
|
303
|
-
reads: number;
|
|
304
|
-
/** Read MiB. The historical `_mb` suffix is retained for schema compatibility. */
|
|
305
|
-
read_bytes_mb: number;
|
|
306
|
-
read_time_ms: number;
|
|
307
|
-
writes: number;
|
|
308
|
-
/** Written MiB. The historical `_mb` suffix is retained for schema compatibility. */
|
|
309
|
-
write_bytes_mb: number;
|
|
310
|
-
write_time_ms: number;
|
|
311
|
-
writebacks: number;
|
|
312
|
-
/** Writeback MiB. Always 0 on PG18+ (op_bytes removed, no writeback byte counts exposed). The historical `_mb` suffix is retained for schema compatibility. */
|
|
313
|
-
writeback_bytes_mb: number;
|
|
314
|
-
writeback_time_ms: number;
|
|
315
|
-
fsyncs: number;
|
|
316
|
-
fsync_time_ms: number;
|
|
317
|
-
/** Relation extension operations reported by pg_stat_io for PostgreSQL 16+. */
|
|
318
|
-
extends?: number;
|
|
319
|
-
/** Extended MiB; PG16 derives extends * op_bytes, PG18+ uses native extend_bytes. */
|
|
320
|
-
extend_bytes_mb?: number;
|
|
321
|
-
hits: number;
|
|
322
|
-
evictions: number;
|
|
323
|
-
reuses: number;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
/**
|
|
327
|
-
* I/O statistics analysis summary (I001)
|
|
328
|
-
*/
|
|
329
|
-
export interface IOAnalysis {
|
|
330
|
-
total_read_mb: number;
|
|
331
|
-
total_write_mb: number;
|
|
332
|
-
/** read_time_ms + write_time_ms across backends. Excludes writeback and fsync time. */
|
|
333
|
-
total_io_time_ms: number;
|
|
334
|
-
/** Buffer hit ratio: hits / (hits + reads) * 100. */
|
|
335
|
-
read_hit_ratio_pct: number;
|
|
336
|
-
/** Average read latency, or null when there are no reads. */
|
|
337
|
-
avg_read_time_ms: number | null;
|
|
338
|
-
/** Average write latency, or null when there are no writes. */
|
|
339
|
-
avg_write_time_ms: number | null;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
/**
|
|
343
|
-
* Node result for reports
|
|
344
|
-
*/
|
|
345
|
-
export interface NodeResult {
|
|
346
|
-
data: Record<string, any>;
|
|
347
|
-
postgres_version?: PostgresVersion;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/**
|
|
351
|
-
* Report structure matching JSON schemas
|
|
352
|
-
*/
|
|
353
|
-
export interface Report {
|
|
354
|
-
version: string | null;
|
|
355
|
-
build_ts: string | null;
|
|
356
|
-
generation_mode: string | null;
|
|
357
|
-
checkId: string;
|
|
358
|
-
checkTitle: string;
|
|
359
|
-
timestamptz: string;
|
|
360
|
-
nodes: {
|
|
361
|
-
primary: string;
|
|
362
|
-
standbys: string[];
|
|
363
|
-
};
|
|
364
|
-
results: Record<string, NodeResult>;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
/**
|
|
368
|
-
* Parse PostgreSQL version number into major and minor components
|
|
369
|
-
*/
|
|
370
|
-
export function parseVersionNum(versionNum: string): { major: string; minor: string } {
|
|
371
|
-
if (!versionNum || versionNum.length < 6) {
|
|
372
|
-
return { major: "", minor: "" };
|
|
373
|
-
}
|
|
374
|
-
try {
|
|
375
|
-
const num = parseInt(versionNum, 10);
|
|
376
|
-
return {
|
|
377
|
-
major: Math.floor(num / 10000).toString(),
|
|
378
|
-
minor: (num % 10000).toString(),
|
|
379
|
-
};
|
|
380
|
-
} catch (err) {
|
|
381
|
-
// parseInt shouldn't throw, but handle edge cases defensively
|
|
382
|
-
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
383
|
-
console.error(`[parseVersionNum] Warning: Failed to parse "${versionNum}": ${errorMsg}`);
|
|
384
|
-
return { major: "", minor: "" };
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/**
|
|
389
|
-
* Format bytes to human readable string using binary units (1024-based).
|
|
390
|
-
* Uses IEC standard: KiB, MiB, GiB, etc.
|
|
391
|
-
*
|
|
392
|
-
* Note: PostgreSQL's pg_size_pretty() uses kB/MB/GB with 1024 base (technically
|
|
393
|
-
* incorrect SI usage), but we follow IEC binary units per project style guide.
|
|
394
|
-
*/
|
|
395
|
-
export function formatBytes(bytes: number): string {
|
|
396
|
-
if (bytes === 0) return "0 B";
|
|
397
|
-
if (bytes < 0) return `-${formatBytes(-bytes)}`; // Handle negative values
|
|
398
|
-
if (!Number.isFinite(bytes)) return `${bytes} B`; // Handle NaN/Infinity
|
|
399
|
-
const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
|
|
400
|
-
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
|
401
|
-
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
/**
|
|
405
|
-
* Format a setting's pretty value from the normalized value and unit.
|
|
406
|
-
* The settings metric provides setting_normalized (bytes or seconds) and unit_normalized.
|
|
407
|
-
*/
|
|
408
|
-
function formatSettingPrettyValue(
|
|
409
|
-
settingNormalized: number | null,
|
|
410
|
-
unitNormalized: string | null,
|
|
411
|
-
rawValue: string
|
|
412
|
-
): string {
|
|
413
|
-
if (settingNormalized === null || unitNormalized === null) {
|
|
414
|
-
return rawValue;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
if (unitNormalized === "bytes") {
|
|
418
|
-
return formatBytes(settingNormalized);
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
if (unitNormalized === "seconds") {
|
|
422
|
-
// Format time values with appropriate units based on magnitude:
|
|
423
|
-
// - Sub-second values (< 1s): show in milliseconds for precision
|
|
424
|
-
// - Small values (< 60s): show in seconds
|
|
425
|
-
// - Larger values (>= 60s): show in minutes for readability
|
|
426
|
-
const MS_PER_SECOND = 1000;
|
|
427
|
-
if (settingNormalized < 1) {
|
|
428
|
-
return `${(settingNormalized * MS_PER_SECOND).toFixed(0)} ms`;
|
|
429
|
-
} else if (settingNormalized < SECONDS_PER_MINUTE) {
|
|
430
|
-
return `${settingNormalized} s`;
|
|
431
|
-
} else {
|
|
432
|
-
return `${(settingNormalized / SECONDS_PER_MINUTE).toFixed(1)} min`;
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
return rawValue;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/**
|
|
440
|
-
* Get PostgreSQL version information.
|
|
441
|
-
* Uses simple inline SQL (trivial query, CLI-specific).
|
|
442
|
-
*
|
|
443
|
-
* @throws {Error} If database query fails (propagating - critical data)
|
|
444
|
-
*/
|
|
445
|
-
export async function getPostgresVersion(client: Client): Promise<PostgresVersion> {
|
|
446
|
-
const result = await client.query(`
|
|
447
|
-
select name, setting
|
|
448
|
-
from pg_settings
|
|
449
|
-
where name in ('server_version', 'server_version_num')
|
|
450
|
-
`);
|
|
451
|
-
|
|
452
|
-
let version = "";
|
|
453
|
-
let serverVersionNum = "";
|
|
454
|
-
|
|
455
|
-
for (const row of result.rows) {
|
|
456
|
-
if (row.name === "server_version") {
|
|
457
|
-
version = row.setting;
|
|
458
|
-
} else if (row.name === "server_version_num") {
|
|
459
|
-
serverVersionNum = row.setting;
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
const { major, minor } = parseVersionNum(serverVersionNum);
|
|
464
|
-
|
|
465
|
-
return {
|
|
466
|
-
version,
|
|
467
|
-
server_version_num: serverVersionNum,
|
|
468
|
-
server_major_ver: major,
|
|
469
|
-
server_minor_ver: minor,
|
|
470
|
-
};
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
/**
|
|
474
|
-
* Get all PostgreSQL settings
|
|
475
|
-
* Uses 'settings' metric from metrics.yml
|
|
476
|
-
*/
|
|
477
|
-
export async function getSettings(client: Client, pgMajorVersion: number = 16): Promise<Record<string, SettingInfo>> {
|
|
478
|
-
const sql = getMetricSql(METRIC_NAMES.settings, pgMajorVersion);
|
|
479
|
-
const result = await client.query(sql);
|
|
480
|
-
const settings: Record<string, SettingInfo> = {};
|
|
481
|
-
|
|
482
|
-
for (const row of result.rows) {
|
|
483
|
-
// The settings metric uses tag_setting_name, tag_setting_value, etc.
|
|
484
|
-
const name = row.tag_setting_name;
|
|
485
|
-
const settingValue = row.tag_setting_value;
|
|
486
|
-
const unit = row.tag_unit || "";
|
|
487
|
-
const category = row.tag_category || "";
|
|
488
|
-
const vartype = row.tag_vartype || "";
|
|
489
|
-
const settingNormalized = row.setting_normalized !== null ? parseFloat(row.setting_normalized) : null;
|
|
490
|
-
const unitNormalized = row.unit_normalized || null;
|
|
491
|
-
|
|
492
|
-
settings[name] = {
|
|
493
|
-
setting: settingValue,
|
|
494
|
-
unit,
|
|
495
|
-
category,
|
|
496
|
-
context: "", // Not available in the monitoring metric
|
|
497
|
-
vartype,
|
|
498
|
-
pretty_value: formatSettingPrettyValue(settingNormalized, unitNormalized, settingValue),
|
|
499
|
-
};
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
return settings;
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
/**
|
|
506
|
-
* Get altered (non-default) PostgreSQL settings
|
|
507
|
-
* Uses 'settings' metric from metrics.yml and filters for non-default
|
|
508
|
-
*/
|
|
509
|
-
export async function getAlteredSettings(client: Client, pgMajorVersion: number = 16): Promise<Record<string, AlteredSetting>> {
|
|
510
|
-
const sql = getMetricSql(METRIC_NAMES.settings, pgMajorVersion);
|
|
511
|
-
const result = await client.query(sql);
|
|
512
|
-
const settings: Record<string, AlteredSetting> = {};
|
|
513
|
-
|
|
514
|
-
for (const row of result.rows) {
|
|
515
|
-
// Filter for non-default settings (is_default = 0 means non-default)
|
|
516
|
-
if (!toBool(row.is_default)) {
|
|
517
|
-
const name = row.tag_setting_name;
|
|
518
|
-
const settingValue = row.tag_setting_value;
|
|
519
|
-
const unit = row.tag_unit || "";
|
|
520
|
-
const category = row.tag_category || "";
|
|
521
|
-
const settingNormalized = row.setting_normalized !== null ? parseFloat(row.setting_normalized) : null;
|
|
522
|
-
const unitNormalized = row.unit_normalized || null;
|
|
523
|
-
|
|
524
|
-
settings[name] = {
|
|
525
|
-
value: settingValue,
|
|
526
|
-
unit,
|
|
527
|
-
category,
|
|
528
|
-
pretty_value: formatSettingPrettyValue(settingNormalized, unitNormalized, settingValue),
|
|
529
|
-
};
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
return settings;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
/**
|
|
537
|
-
* Get database sizes (all non-template databases)
|
|
538
|
-
* Uses simple inline SQL (lists all databases, CLI-specific)
|
|
539
|
-
*/
|
|
540
|
-
export async function getDatabaseSizes(client: Client): Promise<Record<string, number>> {
|
|
541
|
-
const result = await client.query(`
|
|
542
|
-
select
|
|
543
|
-
datname,
|
|
544
|
-
pg_database_size(datname) as size_bytes
|
|
545
|
-
from pg_database
|
|
546
|
-
where datistemplate = false
|
|
547
|
-
order by size_bytes desc
|
|
548
|
-
`);
|
|
549
|
-
const sizes: Record<string, number> = {};
|
|
550
|
-
|
|
551
|
-
for (const row of result.rows) {
|
|
552
|
-
sizes[row.datname] = parseInt(row.size_bytes, 10);
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
return sizes;
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
/**
|
|
559
|
-
* Get cluster general info metrics
|
|
560
|
-
* Uses 'db_stats' metric and inline SQL for connection states/uptime
|
|
561
|
-
*/
|
|
562
|
-
export async function getClusterInfo(client: Client, pgMajorVersion: number = 16): Promise<Record<string, ClusterMetric>> {
|
|
563
|
-
const info: Record<string, ClusterMetric> = {};
|
|
564
|
-
|
|
565
|
-
// Get database statistics from db_stats metric
|
|
566
|
-
const dbStatsSql = getMetricSql(METRIC_NAMES.dbStats, pgMajorVersion);
|
|
567
|
-
const statsResult = await client.query(dbStatsSql);
|
|
568
|
-
if (statsResult.rows.length > 0) {
|
|
569
|
-
const stats = statsResult.rows[0];
|
|
570
|
-
|
|
571
|
-
info.total_connections = {
|
|
572
|
-
value: String(stats.numbackends || 0),
|
|
573
|
-
unit: "connections",
|
|
574
|
-
description: "Current database connections",
|
|
575
|
-
};
|
|
576
|
-
|
|
577
|
-
info.total_commits = {
|
|
578
|
-
value: String(stats.xact_commit || 0),
|
|
579
|
-
unit: "transactions",
|
|
580
|
-
description: "Total committed transactions",
|
|
581
|
-
};
|
|
582
|
-
|
|
583
|
-
info.total_rollbacks = {
|
|
584
|
-
value: String(stats.xact_rollback || 0),
|
|
585
|
-
unit: "transactions",
|
|
586
|
-
description: "Total rolled back transactions",
|
|
587
|
-
};
|
|
588
|
-
|
|
589
|
-
const blocksHit = parseInt(stats.blks_hit || "0", 10);
|
|
590
|
-
const blocksRead = parseInt(stats.blks_read || "0", 10);
|
|
591
|
-
const totalBlocks = blocksHit + blocksRead;
|
|
592
|
-
const cacheHitRatio = totalBlocks > 0 ? ((blocksHit / totalBlocks) * 100).toFixed(2) : "0.00";
|
|
593
|
-
|
|
594
|
-
info.cache_hit_ratio = {
|
|
595
|
-
value: cacheHitRatio,
|
|
596
|
-
unit: "%",
|
|
597
|
-
description: "Buffer cache hit ratio",
|
|
598
|
-
};
|
|
599
|
-
|
|
600
|
-
info.blocks_read = {
|
|
601
|
-
value: String(blocksRead),
|
|
602
|
-
unit: "blocks",
|
|
603
|
-
description: "Total disk blocks read",
|
|
604
|
-
};
|
|
605
|
-
|
|
606
|
-
info.blocks_hit = {
|
|
607
|
-
value: String(blocksHit),
|
|
608
|
-
unit: "blocks",
|
|
609
|
-
description: "Total buffer cache hits",
|
|
610
|
-
};
|
|
611
|
-
|
|
612
|
-
info.tuples_returned = {
|
|
613
|
-
value: String(stats.tup_returned || 0),
|
|
614
|
-
unit: "rows",
|
|
615
|
-
description: "Total rows returned by queries",
|
|
616
|
-
};
|
|
617
|
-
|
|
618
|
-
info.tuples_fetched = {
|
|
619
|
-
value: String(stats.tup_fetched || 0),
|
|
620
|
-
unit: "rows",
|
|
621
|
-
description: "Total rows fetched by queries",
|
|
622
|
-
};
|
|
623
|
-
|
|
624
|
-
info.tuples_inserted = {
|
|
625
|
-
value: String(stats.tup_inserted || 0),
|
|
626
|
-
unit: "rows",
|
|
627
|
-
description: "Total rows inserted",
|
|
628
|
-
};
|
|
629
|
-
|
|
630
|
-
info.tuples_updated = {
|
|
631
|
-
value: String(stats.tup_updated || 0),
|
|
632
|
-
unit: "rows",
|
|
633
|
-
description: "Total rows updated",
|
|
634
|
-
};
|
|
635
|
-
|
|
636
|
-
info.tuples_deleted = {
|
|
637
|
-
value: String(stats.tup_deleted || 0),
|
|
638
|
-
unit: "rows",
|
|
639
|
-
description: "Total rows deleted",
|
|
640
|
-
};
|
|
641
|
-
|
|
642
|
-
info.total_deadlocks = {
|
|
643
|
-
value: String(stats.deadlocks || 0),
|
|
644
|
-
unit: "deadlocks",
|
|
645
|
-
description: "Total deadlocks detected",
|
|
646
|
-
};
|
|
647
|
-
|
|
648
|
-
info.temp_files_created = {
|
|
649
|
-
value: String(stats.temp_files || 0),
|
|
650
|
-
unit: "files",
|
|
651
|
-
description: "Total temporary files created",
|
|
652
|
-
};
|
|
653
|
-
|
|
654
|
-
const tempBytes = parseInt(stats.temp_bytes || "0", 10);
|
|
655
|
-
info.temp_bytes_written = {
|
|
656
|
-
value: formatBytes(tempBytes),
|
|
657
|
-
unit: "bytes",
|
|
658
|
-
description: "Total temporary file bytes written",
|
|
659
|
-
};
|
|
660
|
-
|
|
661
|
-
// Uptime from db_stats
|
|
662
|
-
if (stats.postmaster_uptime_s) {
|
|
663
|
-
const uptimeSeconds = parseInt(stats.postmaster_uptime_s, 10);
|
|
664
|
-
const days = Math.floor(uptimeSeconds / SECONDS_PER_DAY);
|
|
665
|
-
const hours = Math.floor((uptimeSeconds % SECONDS_PER_DAY) / SECONDS_PER_HOUR);
|
|
666
|
-
const minutes = Math.floor((uptimeSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE);
|
|
667
|
-
info.uptime = {
|
|
668
|
-
value: `${days} days ${hours}:${String(minutes).padStart(2, "0")}:${String(uptimeSeconds % SECONDS_PER_MINUTE).padStart(2, "0")}`,
|
|
669
|
-
unit: "interval",
|
|
670
|
-
description: "Server uptime",
|
|
671
|
-
};
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
// Get connection states (simple inline SQL)
|
|
676
|
-
const connResult = await client.query(`
|
|
677
|
-
select
|
|
678
|
-
coalesce(state, 'null') as state,
|
|
679
|
-
count(*) as count
|
|
680
|
-
from pg_stat_activity
|
|
681
|
-
group by state
|
|
682
|
-
`);
|
|
683
|
-
for (const row of connResult.rows) {
|
|
684
|
-
const stateKey = `connections_${row.state.replace(/\s+/g, "_")}`;
|
|
685
|
-
info[stateKey] = {
|
|
686
|
-
value: String(row.count),
|
|
687
|
-
unit: "connections",
|
|
688
|
-
description: `Connections in '${row.state}' state`,
|
|
689
|
-
};
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
// Get uptime info (simple inline SQL)
|
|
693
|
-
const uptimeResult = await client.query(`
|
|
694
|
-
select
|
|
695
|
-
pg_postmaster_start_time() as start_time,
|
|
696
|
-
current_timestamp - pg_postmaster_start_time() as uptime
|
|
697
|
-
`);
|
|
698
|
-
if (uptimeResult.rows.length > 0) {
|
|
699
|
-
const uptime = uptimeResult.rows[0];
|
|
700
|
-
const startTime = uptime.start_time instanceof Date
|
|
701
|
-
? uptime.start_time.toISOString()
|
|
702
|
-
: String(uptime.start_time);
|
|
703
|
-
info.start_time = {
|
|
704
|
-
value: startTime,
|
|
705
|
-
unit: "timestamp",
|
|
706
|
-
description: "PostgreSQL server start time",
|
|
707
|
-
};
|
|
708
|
-
if (!info.uptime) {
|
|
709
|
-
info.uptime = {
|
|
710
|
-
value: String(uptime.uptime),
|
|
711
|
-
unit: "interval",
|
|
712
|
-
description: "Server uptime",
|
|
713
|
-
};
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
return info;
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
/**
|
|
721
|
-
* Get invalid indexes from the database (H001).
|
|
722
|
-
* Invalid indexes have indisvalid = false, typically from failed CREATE INDEX CONCURRENTLY.
|
|
723
|
-
*
|
|
724
|
-
* @param client - Connected PostgreSQL client
|
|
725
|
-
* @param pgMajorVersion - PostgreSQL major version (default: 16)
|
|
726
|
-
* @returns Array of invalid index entries with observation data for decision tree analysis
|
|
727
|
-
*/
|
|
728
|
-
export async function getInvalidIndexes(client: Client, pgMajorVersion: number = 16): Promise<InvalidIndex[]> {
|
|
729
|
-
const sql = getMetricSql(METRIC_NAMES.H001, pgMajorVersion);
|
|
730
|
-
const result = await client.query(sql);
|
|
731
|
-
return result.rows.map((row) => {
|
|
732
|
-
const transformed = transformMetricRow(row);
|
|
733
|
-
const indexSizeBytes = parseInt(String(transformed.index_size_bytes || 0), 10);
|
|
734
|
-
|
|
735
|
-
return {
|
|
736
|
-
schema_name: String(transformed.schema_name || ""),
|
|
737
|
-
table_name: String(transformed.table_name || ""),
|
|
738
|
-
index_name: String(transformed.index_name || ""),
|
|
739
|
-
relation_name: String(transformed.relation_name || ""),
|
|
740
|
-
index_size_bytes: indexSizeBytes,
|
|
741
|
-
index_size_pretty: formatBytes(indexSizeBytes),
|
|
742
|
-
index_definition: String(transformed.index_definition || ""),
|
|
743
|
-
supports_fk: toBool(transformed.supports_fk),
|
|
744
|
-
is_pk: toBool(transformed.is_pk),
|
|
745
|
-
is_unique: toBool(transformed.is_unique),
|
|
746
|
-
constraint_name: transformed.constraint_name ? String(transformed.constraint_name) : null,
|
|
747
|
-
table_row_estimate: parseInt(String(transformed.table_row_estimate || 0), 10),
|
|
748
|
-
has_valid_duplicate: toBool(transformed.has_valid_duplicate),
|
|
749
|
-
valid_duplicate_name: transformed.valid_index_name ? String(transformed.valid_index_name) : null,
|
|
750
|
-
valid_duplicate_definition: transformed.valid_index_definition ? String(transformed.valid_index_definition) : null,
|
|
751
|
-
};
|
|
752
|
-
});
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
/**
|
|
756
|
-
* Get unused indexes from the database (H002).
|
|
757
|
-
* Unused indexes have zero scans since stats were last reset.
|
|
758
|
-
*
|
|
759
|
-
* @param client - Connected PostgreSQL client
|
|
760
|
-
* @param pgMajorVersion - PostgreSQL major version (default: 16)
|
|
761
|
-
* @returns Array of unused index entries with scan counts and FK support info
|
|
762
|
-
*/
|
|
763
|
-
export async function getUnusedIndexes(client: Client, pgMajorVersion: number = 16): Promise<UnusedIndex[]> {
|
|
764
|
-
const sql = getMetricSql(METRIC_NAMES.H002, pgMajorVersion);
|
|
765
|
-
const result = await client.query(sql);
|
|
766
|
-
return result.rows.map((row) => {
|
|
767
|
-
const transformed = transformMetricRow(row);
|
|
768
|
-
const indexSizeBytes = parseInt(String(transformed.index_size_bytes || 0), 10);
|
|
769
|
-
return {
|
|
770
|
-
schema_name: String(transformed.schema_name || ""),
|
|
771
|
-
table_name: String(transformed.table_name || ""),
|
|
772
|
-
index_name: String(transformed.index_name || ""),
|
|
773
|
-
index_definition: String(transformed.index_definition || ""),
|
|
774
|
-
reason: String(transformed.reason || ""),
|
|
775
|
-
idx_scan: parseInt(String(transformed.idx_scan || 0), 10),
|
|
776
|
-
index_size_bytes: indexSizeBytes,
|
|
777
|
-
idx_is_btree: toBool(transformed.idx_is_btree),
|
|
778
|
-
supports_fk: toBool(transformed.supports_fk),
|
|
779
|
-
index_size_pretty: formatBytes(indexSizeBytes),
|
|
780
|
-
};
|
|
781
|
-
});
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
/**
|
|
785
|
-
* Get stats reset info (H002)
|
|
786
|
-
* SQL loaded from config/pgwatch-prometheus/metrics.yml (stats_reset)
|
|
787
|
-
*/
|
|
788
|
-
export async function getStatsReset(client: Client, pgMajorVersion: number = 16): Promise<StatsReset> {
|
|
789
|
-
const sql = getMetricSql(METRIC_NAMES.statsReset, pgMajorVersion);
|
|
790
|
-
const result = await client.query(sql);
|
|
791
|
-
const row = result.rows[0] || {};
|
|
792
|
-
|
|
793
|
-
// The stats_reset metric returns stats_reset_epoch and seconds_since_reset
|
|
794
|
-
// We need to calculate additional fields
|
|
795
|
-
const statsResetEpoch = row.stats_reset_epoch ? parseFloat(row.stats_reset_epoch) : null;
|
|
796
|
-
const secondsSinceReset = row.seconds_since_reset ? parseInt(row.seconds_since_reset, 10) : null;
|
|
797
|
-
|
|
798
|
-
// Calculate stats_reset_time from epoch
|
|
799
|
-
const statsResetTime = statsResetEpoch
|
|
800
|
-
? new Date(statsResetEpoch * 1000).toISOString()
|
|
801
|
-
: null;
|
|
802
|
-
|
|
803
|
-
// Calculate days since reset
|
|
804
|
-
const daysSinceReset = secondsSinceReset !== null
|
|
805
|
-
? Math.floor(secondsSinceReset / SECONDS_PER_DAY)
|
|
806
|
-
: null;
|
|
807
|
-
|
|
808
|
-
// Get postmaster startup time separately (simple inline SQL)
|
|
809
|
-
// This is supplementary data - errors are captured in output, not propagated
|
|
810
|
-
let postmasterStartupEpoch: number | null = null;
|
|
811
|
-
let postmasterStartupTime: string | null = null;
|
|
812
|
-
let postmasterStartupError: string | undefined;
|
|
813
|
-
try {
|
|
814
|
-
const pmResult = await client.query(`
|
|
815
|
-
select
|
|
816
|
-
extract(epoch from pg_postmaster_start_time()) as postmaster_startup_epoch,
|
|
817
|
-
pg_postmaster_start_time()::text as postmaster_startup_time
|
|
818
|
-
`);
|
|
819
|
-
if (pmResult.rows.length > 0) {
|
|
820
|
-
postmasterStartupEpoch = pmResult.rows[0].postmaster_startup_epoch
|
|
821
|
-
? parseFloat(pmResult.rows[0].postmaster_startup_epoch)
|
|
822
|
-
: null;
|
|
823
|
-
postmasterStartupTime = pmResult.rows[0].postmaster_startup_time || null;
|
|
824
|
-
}
|
|
825
|
-
} catch (err) {
|
|
826
|
-
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
827
|
-
postmasterStartupError = `Failed to query postmaster start time: ${errorMsg}`;
|
|
828
|
-
console.error(`[getStatsReset] Warning: ${postmasterStartupError}`);
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
const statsResult: StatsReset = {
|
|
832
|
-
stats_reset_epoch: statsResetEpoch,
|
|
833
|
-
stats_reset_time: statsResetTime,
|
|
834
|
-
days_since_reset: daysSinceReset,
|
|
835
|
-
postmaster_startup_epoch: postmasterStartupEpoch,
|
|
836
|
-
postmaster_startup_time: postmasterStartupTime,
|
|
837
|
-
};
|
|
838
|
-
|
|
839
|
-
// Only include error field if there was an error (keeps output clean)
|
|
840
|
-
if (postmasterStartupError) {
|
|
841
|
-
statsResult.postmaster_startup_error = postmasterStartupError;
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
return statsResult;
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
/**
|
|
848
|
-
* Get current database name and size
|
|
849
|
-
* Uses 'db_size' metric from metrics.yml
|
|
850
|
-
*/
|
|
851
|
-
export async function getCurrentDatabaseInfo(client: Client, pgMajorVersion: number = 16): Promise<{ datname: string; size_bytes: number }> {
|
|
852
|
-
const sql = getMetricSql(METRIC_NAMES.dbSize, pgMajorVersion);
|
|
853
|
-
const result = await client.query(sql);
|
|
854
|
-
const row = result.rows[0] || {};
|
|
855
|
-
|
|
856
|
-
// db_size metric returns tag_datname and size_b
|
|
857
|
-
return {
|
|
858
|
-
datname: row.tag_datname || "postgres",
|
|
859
|
-
size_bytes: parseInt(row.size_b || "0", 10),
|
|
860
|
-
};
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
/**
|
|
864
|
-
* Type guard to validate redundant_to_json item structure.
|
|
865
|
-
* Returns true if item is a valid object (may have expected properties).
|
|
866
|
-
*/
|
|
867
|
-
function isValidRedundantToItem(item: unknown): item is Record<string, unknown> {
|
|
868
|
-
return typeof item === "object" && item !== null && !Array.isArray(item);
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
/**
|
|
872
|
-
* Get redundant indexes from the database (H004).
|
|
873
|
-
* Redundant indexes are covered by other indexes (same leading columns).
|
|
874
|
-
*
|
|
875
|
-
* @param client - Connected PostgreSQL client
|
|
876
|
-
* @param pgMajorVersion - PostgreSQL major version (default: 16)
|
|
877
|
-
* @returns Array of redundant index entries with covering index info
|
|
878
|
-
*/
|
|
879
|
-
export async function getRedundantIndexes(client: Client, pgMajorVersion: number = 16): Promise<RedundantIndex[]> {
|
|
880
|
-
const sql = getMetricSql(METRIC_NAMES.H004, pgMajorVersion);
|
|
881
|
-
const result = await client.query(sql);
|
|
882
|
-
return result.rows.map((row) => {
|
|
883
|
-
const transformed = transformMetricRow(row);
|
|
884
|
-
const indexSizeBytes = parseInt(String(transformed.index_size_bytes || 0), 10);
|
|
885
|
-
const tableSizeBytes = parseInt(String(transformed.table_size_bytes || 0), 10);
|
|
886
|
-
|
|
887
|
-
// Parse redundant_to JSON array (indexes that make this one redundant)
|
|
888
|
-
let redundantTo: RedundantToIndex[] = [];
|
|
889
|
-
let parseError: string | undefined;
|
|
890
|
-
try {
|
|
891
|
-
const jsonStr = String(transformed.redundant_to_json || "[]");
|
|
892
|
-
const parsed = JSON.parse(jsonStr);
|
|
893
|
-
if (Array.isArray(parsed)) {
|
|
894
|
-
redundantTo = parsed
|
|
895
|
-
.filter(isValidRedundantToItem)
|
|
896
|
-
.map((item) => {
|
|
897
|
-
const sizeBytes = parseInt(String(item.index_size_bytes ?? 0), 10);
|
|
898
|
-
return {
|
|
899
|
-
index_name: String(item.index_name ?? ""),
|
|
900
|
-
index_definition: String(item.index_definition ?? ""),
|
|
901
|
-
index_size_bytes: sizeBytes,
|
|
902
|
-
index_size_pretty: formatBytes(sizeBytes),
|
|
903
|
-
};
|
|
904
|
-
});
|
|
905
|
-
}
|
|
906
|
-
} catch (err) {
|
|
907
|
-
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
908
|
-
const indexName = String(transformed.index_name || "unknown");
|
|
909
|
-
parseError = `Failed to parse redundant_to_json: ${errorMsg}`;
|
|
910
|
-
console.error(`[H004] Warning: ${parseError} for index "${indexName}"`);
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
const result: RedundantIndex = {
|
|
914
|
-
schema_name: String(transformed.schema_name || ""),
|
|
915
|
-
table_name: String(transformed.table_name || ""),
|
|
916
|
-
index_name: String(transformed.index_name || ""),
|
|
917
|
-
relation_name: String(transformed.relation_name || ""),
|
|
918
|
-
access_method: String(transformed.access_method || ""),
|
|
919
|
-
reason: String(transformed.reason || ""),
|
|
920
|
-
index_size_bytes: indexSizeBytes,
|
|
921
|
-
table_size_bytes: tableSizeBytes,
|
|
922
|
-
index_usage: parseInt(String(transformed.index_usage || 0), 10),
|
|
923
|
-
supports_fk: toBool(transformed.supports_fk),
|
|
924
|
-
index_definition: String(transformed.index_definition || ""),
|
|
925
|
-
index_size_pretty: formatBytes(indexSizeBytes),
|
|
926
|
-
table_size_pretty: formatBytes(tableSizeBytes),
|
|
927
|
-
redundant_to: redundantTo,
|
|
928
|
-
};
|
|
929
|
-
|
|
930
|
-
// Only include parse error field if there was an error (keeps output clean)
|
|
931
|
-
if (parseError) {
|
|
932
|
-
result.redundant_to_parse_error = parseError;
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
return result;
|
|
936
|
-
});
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
/**
|
|
940
|
-
* Get per-table dead-tuple stats and per-table autovacuum overrides (F003).
|
|
941
|
-
* SQL loaded from config/pgwatch-prometheus/metrics.yml (pg_dead_tuples metric).
|
|
942
|
-
*
|
|
943
|
-
* Returns tables that carry dead tuples or have autovacuum disabled per-table,
|
|
944
|
-
* with threshold flags precomputed (see F003_* constants).
|
|
945
|
-
*
|
|
946
|
-
* @param client - Connected PostgreSQL client
|
|
947
|
-
* @param pgMajorVersion - PostgreSQL major version (default: 16)
|
|
948
|
-
* @throws {Error} If database query fails (propagating - critical data)
|
|
949
|
-
*/
|
|
950
|
-
export async function getDeadTuples(client: Client, pgMajorVersion: number = 16): Promise<DeadTuplesTable[]> {
|
|
951
|
-
const sql = getMetricSql(METRIC_NAMES.F003, pgMajorVersion);
|
|
952
|
-
const result = await client.query(sql);
|
|
953
|
-
return result.rows.map((row) => {
|
|
954
|
-
const t = transformMetricRow(row);
|
|
955
|
-
const nLive = parseInt(String(t.n_live_tup || 0), 10);
|
|
956
|
-
const nDead = parseInt(String(t.n_dead_tup || 0), 10);
|
|
957
|
-
const deadPct = parseFloat(String(t.dead_pct)) || 0;
|
|
958
|
-
const lastAutovacuumEpoch = parseInt(String(t.last_autovacuum || 0), 10);
|
|
959
|
-
const lastVacuumEpoch = parseInt(String(t.last_vacuum || 0), 10);
|
|
960
|
-
// The metric emits 0/1; be liberal in what we accept (driver may return strings)
|
|
961
|
-
const autovacuumDisabled = parseInt(String(t.autovacuum_disabled || 0), 10) === 1 || toBool(t.autovacuum_disabled);
|
|
962
|
-
const tableSizeBytes = parseInt(String(t.table_size_b || 0), 10);
|
|
963
|
-
|
|
964
|
-
return {
|
|
965
|
-
schema_name: String(t.schemaname || ""),
|
|
966
|
-
table_name: String(t.relname || ""),
|
|
967
|
-
n_live_tup: nLive,
|
|
968
|
-
n_dead_tup: nDead,
|
|
969
|
-
dead_pct: deadPct,
|
|
970
|
-
last_autovacuum: lastAutovacuumEpoch > 0 ? new Date(lastAutovacuumEpoch * 1000).toISOString() : null,
|
|
971
|
-
last_autovacuum_epoch: lastAutovacuumEpoch,
|
|
972
|
-
last_vacuum: lastVacuumEpoch > 0 ? new Date(lastVacuumEpoch * 1000).toISOString() : null,
|
|
973
|
-
last_vacuum_epoch: lastVacuumEpoch,
|
|
974
|
-
autovacuum_count: parseInt(String(t.autovacuum_count || 0), 10),
|
|
975
|
-
vacuum_count: parseInt(String(t.vacuum_count || 0), 10),
|
|
976
|
-
autovacuum_disabled: autovacuumDisabled,
|
|
977
|
-
table_size_bytes: tableSizeBytes,
|
|
978
|
-
table_size_pretty: formatBytes(tableSizeBytes),
|
|
979
|
-
exceeds_dead_tuple_thresholds: nDead >= F003_DEAD_TUPLES_MIN && deadPct >= F003_DEAD_PCT_MIN,
|
|
980
|
-
autovacuum_disabled_flagged: autovacuumDisabled && nLive + nDead >= F003_AUTOVACUUM_DISABLED_MIN_ROWS,
|
|
981
|
-
};
|
|
982
|
-
});
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
/**
|
|
986
|
-
* Build concrete, human-readable conclusions and recommendations for F003.
|
|
987
|
-
*
|
|
988
|
-
* Exported separately so the wording (which the console surfaces verbatim in
|
|
989
|
-
* auto-created issues) can be unit-tested without a database.
|
|
990
|
-
*/
|
|
991
|
-
export function buildDeadTuplesConclusions(tables: DeadTuplesTable[]): {
|
|
992
|
-
conclusions: string[];
|
|
993
|
-
recommendations: string[];
|
|
994
|
-
} {
|
|
995
|
-
const conclusions: string[] = [];
|
|
996
|
-
const recommendations: string[] = [];
|
|
997
|
-
|
|
998
|
-
const fmt = (n: number) => n.toLocaleString("en-US");
|
|
999
|
-
|
|
1000
|
-
for (const t of tables) {
|
|
1001
|
-
const rel = `"${t.schema_name}"."${t.table_name}"`;
|
|
1002
|
-
const lastAv = t.last_autovacuum
|
|
1003
|
-
? `last autovacuum: ${t.last_autovacuum}`
|
|
1004
|
-
: "autovacuum has never vacuumed it";
|
|
1005
|
-
|
|
1006
|
-
if (t.exceeds_dead_tuple_thresholds && t.autovacuum_disabled) {
|
|
1007
|
-
conclusions.push(
|
|
1008
|
-
`Table ${rel} has ${fmt(t.n_dead_tup)} dead tuples (${t.dead_pct}% of all tuples) ` +
|
|
1009
|
-
`and autovacuum is disabled on it via reloptions (${lastAv}).`
|
|
1010
|
-
);
|
|
1011
|
-
recommendations.push(
|
|
1012
|
-
`Re-enable autovacuum on ${rel}: alter table ${rel} reset (autovacuum_enabled); ` +
|
|
1013
|
-
`then run: vacuum (analyze) ${rel}; to clean up the accumulated dead tuples.`
|
|
1014
|
-
);
|
|
1015
|
-
} else if (t.exceeds_dead_tuple_thresholds) {
|
|
1016
|
-
conclusions.push(
|
|
1017
|
-
`Table ${rel} has ${fmt(t.n_dead_tup)} dead tuples (${t.dead_pct}% of all tuples; ${lastAv}).`
|
|
1018
|
-
);
|
|
1019
|
-
recommendations.push(
|
|
1020
|
-
`Run: vacuum (analyze) ${rel}; and review autovacuum settings ` +
|
|
1021
|
-
`(autovacuum_vacuum_scale_factor, autovacuum_vacuum_cost_delay, autovacuum_max_workers) ` +
|
|
1022
|
-
`if dead tuples keep accumulating on ${rel}.`
|
|
1023
|
-
);
|
|
1024
|
-
} else if (t.autovacuum_disabled_flagged) {
|
|
1025
|
-
conclusions.push(
|
|
1026
|
-
`Autovacuum is disabled via reloptions on table ${rel} ` +
|
|
1027
|
-
`(~${fmt(t.n_live_tup + t.n_dead_tup)} tuples); dead tuples and transaction ID age ` +
|
|
1028
|
-
`will accumulate unchecked.`
|
|
1029
|
-
);
|
|
1030
|
-
recommendations.push(
|
|
1031
|
-
`Re-enable autovacuum on ${rel}: alter table ${rel} reset (autovacuum_enabled); ` +
|
|
1032
|
-
`unless this table is managed by a carefully scheduled manual vacuum job.`
|
|
1033
|
-
);
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
return { conclusions, recommendations };
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
/**
|
|
1041
|
-
* Create base report structure
|
|
1042
|
-
*/
|
|
1043
|
-
export function createBaseReport(
|
|
1044
|
-
checkId: string,
|
|
1045
|
-
checkTitle: string,
|
|
1046
|
-
nodeName: string
|
|
1047
|
-
): Report {
|
|
1048
|
-
const buildTs = resolveBuildTs();
|
|
1049
|
-
return {
|
|
1050
|
-
version: pkg.version || null,
|
|
1051
|
-
build_ts: buildTs,
|
|
1052
|
-
generation_mode: "express",
|
|
1053
|
-
checkId,
|
|
1054
|
-
checkTitle,
|
|
1055
|
-
timestamptz: new Date().toISOString(),
|
|
1056
|
-
nodes: {
|
|
1057
|
-
primary: nodeName,
|
|
1058
|
-
standbys: [],
|
|
1059
|
-
},
|
|
1060
|
-
results: {},
|
|
1061
|
-
};
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
function readTextFileSafe(p: string): string | null {
|
|
1065
|
-
try {
|
|
1066
|
-
const value = fs.readFileSync(p, "utf8").trim();
|
|
1067
|
-
return value || null;
|
|
1068
|
-
} catch {
|
|
1069
|
-
// Intentionally silent: this is a "safe" read that returns null on any error
|
|
1070
|
-
// (file not found, permission denied, etc.) - used for optional config files
|
|
1071
|
-
return null;
|
|
1072
|
-
}
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
function resolveBuildTs(): string | null {
|
|
1076
|
-
// Follow reporter.py approach: read BUILD_TS from filesystem, with env override.
|
|
1077
|
-
// Default: /BUILD_TS (useful in container images).
|
|
1078
|
-
const envPath = process.env.PGAI_BUILD_TS_FILE;
|
|
1079
|
-
const p = (envPath && envPath.trim()) ? envPath.trim() : "/BUILD_TS";
|
|
1080
|
-
|
|
1081
|
-
const fromFile = readTextFileSafe(p);
|
|
1082
|
-
if (fromFile) return fromFile;
|
|
1083
|
-
|
|
1084
|
-
// Fallback for packaged CLI: allow placing BUILD_TS next to dist/ (package root).
|
|
1085
|
-
// dist/lib/checkup.js => package root: dist/..
|
|
1086
|
-
try {
|
|
1087
|
-
const pkgRoot = path.resolve(__dirname, "..");
|
|
1088
|
-
const fromPkgFile = readTextFileSafe(path.join(pkgRoot, "BUILD_TS"));
|
|
1089
|
-
if (fromPkgFile) return fromPkgFile;
|
|
1090
|
-
} catch (err) {
|
|
1091
|
-
// Path resolution failing is unexpected - warn about it
|
|
1092
|
-
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1093
|
-
console.warn(`[resolveBuildTs] Warning: path resolution failed: ${errorMsg}`);
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
// Last resort: use package.json mtime as an approximation (non-null, stable-ish).
|
|
1097
|
-
try {
|
|
1098
|
-
const pkgJsonPath = path.resolve(__dirname, "..", "package.json");
|
|
1099
|
-
const st = fs.statSync(pkgJsonPath);
|
|
1100
|
-
return st.mtime.toISOString();
|
|
1101
|
-
} catch (err) {
|
|
1102
|
-
// package.json not found is expected in some environments (e.g., bundled) - debug only
|
|
1103
|
-
if (process.env.DEBUG) {
|
|
1104
|
-
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1105
|
-
console.error(`[resolveBuildTs] Could not stat package.json, using current time: ${errorMsg}`);
|
|
1106
|
-
}
|
|
1107
|
-
return new Date().toISOString();
|
|
1108
|
-
}
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
// ============================================================================
|
|
1112
|
-
// Unified Report Generator Helpers
|
|
1113
|
-
// ============================================================================
|
|
1114
|
-
|
|
1115
|
-
/**
|
|
1116
|
-
* Generate a simple version report (A002, A013).
|
|
1117
|
-
* These reports only contain PostgreSQL version information.
|
|
1118
|
-
*/
|
|
1119
|
-
async function generateVersionReport(
|
|
1120
|
-
client: Client,
|
|
1121
|
-
nodeName: string,
|
|
1122
|
-
checkId: string,
|
|
1123
|
-
checkTitle: string
|
|
1124
|
-
): Promise<Report> {
|
|
1125
|
-
const report = createBaseReport(checkId, checkTitle, nodeName);
|
|
1126
|
-
const postgresVersion = await getPostgresVersion(client);
|
|
1127
|
-
report.results[nodeName] = { data: { version: postgresVersion } };
|
|
1128
|
-
return report;
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
/**
|
|
1132
|
-
* Generate a settings-based report (A003, A007).
|
|
1133
|
-
* Fetches settings using provided function and includes postgres_version.
|
|
1134
|
-
*/
|
|
1135
|
-
async function generateSettingsReport(
|
|
1136
|
-
client: Client,
|
|
1137
|
-
nodeName: string,
|
|
1138
|
-
checkId: string,
|
|
1139
|
-
checkTitle: string,
|
|
1140
|
-
fetchSettings: (client: Client, pgMajorVersion: number) => Promise<Record<string, unknown>>
|
|
1141
|
-
): Promise<Report> {
|
|
1142
|
-
const report = createBaseReport(checkId, checkTitle, nodeName);
|
|
1143
|
-
const postgresVersion = await getPostgresVersion(client);
|
|
1144
|
-
const pgMajorVersion = parseInt(postgresVersion.server_major_ver, 10) || 16;
|
|
1145
|
-
const settings = await fetchSettings(client, pgMajorVersion);
|
|
1146
|
-
report.results[nodeName] = { data: settings, postgres_version: postgresVersion };
|
|
1147
|
-
return report;
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
/**
|
|
1151
|
-
* Generate an index report (H001, H002, H004).
|
|
1152
|
-
* Common structure: index list + totals + database info, keyed by database name.
|
|
1153
|
-
*/
|
|
1154
|
-
async function generateIndexReport<T extends { index_size_bytes: number }>(
|
|
1155
|
-
client: Client,
|
|
1156
|
-
nodeName: string,
|
|
1157
|
-
checkId: string,
|
|
1158
|
-
checkTitle: string,
|
|
1159
|
-
indexFieldName: string,
|
|
1160
|
-
fetchIndexes: (client: Client, pgMajorVersion: number) => Promise<T[]>,
|
|
1161
|
-
extraFields?: (client: Client, pgMajorVersion: number) => Promise<Record<string, unknown>>
|
|
1162
|
-
): Promise<Report> {
|
|
1163
|
-
const report = createBaseReport(checkId, checkTitle, nodeName);
|
|
1164
|
-
const postgresVersion = await getPostgresVersion(client);
|
|
1165
|
-
const pgMajorVersion = parseInt(postgresVersion.server_major_ver, 10) || 16;
|
|
1166
|
-
const indexes = await fetchIndexes(client, pgMajorVersion);
|
|
1167
|
-
const { datname: dbName, size_bytes: dbSizeBytes } = await getCurrentDatabaseInfo(client, pgMajorVersion);
|
|
1168
|
-
|
|
1169
|
-
const totalCount = indexes.length;
|
|
1170
|
-
const totalSizeBytes = indexes.reduce((sum, idx) => sum + idx.index_size_bytes, 0);
|
|
1171
|
-
|
|
1172
|
-
const dbEntry: Record<string, unknown> = {
|
|
1173
|
-
[indexFieldName]: indexes,
|
|
1174
|
-
total_count: totalCount,
|
|
1175
|
-
total_size_bytes: totalSizeBytes,
|
|
1176
|
-
total_size_pretty: formatBytes(totalSizeBytes),
|
|
1177
|
-
database_size_bytes: dbSizeBytes,
|
|
1178
|
-
database_size_pretty: formatBytes(dbSizeBytes),
|
|
1179
|
-
};
|
|
1180
|
-
|
|
1181
|
-
// Add extra fields if provided (e.g., stats_reset for H002)
|
|
1182
|
-
if (extraFields) {
|
|
1183
|
-
Object.assign(dbEntry, await extraFields(client, pgMajorVersion));
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
report.results[nodeName] = { data: { [dbName]: dbEntry }, postgres_version: postgresVersion };
|
|
1187
|
-
return report;
|
|
1188
|
-
}
|
|
1189
|
-
|
|
1190
|
-
// ============================================================================
|
|
1191
|
-
// Report Generators (using unified helpers)
|
|
1192
|
-
// ============================================================================
|
|
1193
|
-
|
|
1194
|
-
/** Generate A002 report - Postgres major version */
|
|
1195
|
-
export const generateA002 = (client: Client, nodeName = "node-01") =>
|
|
1196
|
-
generateVersionReport(client, nodeName, "A002", "Postgres major version");
|
|
1197
|
-
|
|
1198
|
-
/** Generate A003 report - Postgres settings */
|
|
1199
|
-
export const generateA003 = (client: Client, nodeName = "node-01") =>
|
|
1200
|
-
generateSettingsReport(client, nodeName, "A003", "Postgres settings", getSettings);
|
|
1201
|
-
|
|
1202
|
-
/** Generate A004 report - Cluster information (custom structure) */
|
|
1203
|
-
export async function generateA004(client: Client, nodeName: string = "node-01"): Promise<Report> {
|
|
1204
|
-
const report = createBaseReport("A004", "Cluster information", nodeName);
|
|
1205
|
-
const postgresVersion = await getPostgresVersion(client);
|
|
1206
|
-
const pgMajorVersion = parseInt(postgresVersion.server_major_ver, 10) || 16;
|
|
1207
|
-
report.results[nodeName] = {
|
|
1208
|
-
data: {
|
|
1209
|
-
general_info: await getClusterInfo(client, pgMajorVersion),
|
|
1210
|
-
database_sizes: await getDatabaseSizes(client),
|
|
1211
|
-
},
|
|
1212
|
-
postgres_version: postgresVersion,
|
|
1213
|
-
};
|
|
1214
|
-
return report;
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
/** Generate A007 report - Altered settings */
|
|
1218
|
-
export const generateA007 = (client: Client, nodeName = "node-01") =>
|
|
1219
|
-
generateSettingsReport(client, nodeName, "A007", "Altered settings", getAlteredSettings);
|
|
1220
|
-
|
|
1221
|
-
/** Generate A013 report - Postgres minor version */
|
|
1222
|
-
export const generateA013 = (client: Client, nodeName = "node-01") =>
|
|
1223
|
-
generateVersionReport(client, nodeName, "A013", "Postgres minor version");
|
|
1224
|
-
|
|
1225
|
-
/** Generate H001 report - Invalid indexes */
|
|
1226
|
-
export const generateH001 = (client: Client, nodeName = "node-01") =>
|
|
1227
|
-
generateIndexReport(client, nodeName, "H001", "Invalid indexes", "invalid_indexes", getInvalidIndexes);
|
|
1228
|
-
|
|
1229
|
-
/** Generate H002 report - Unused indexes (includes stats_reset) */
|
|
1230
|
-
export const generateH002 = (client: Client, nodeName = "node-01") =>
|
|
1231
|
-
generateIndexReport(client, nodeName, "H002", "Unused indexes", "unused_indexes", getUnusedIndexes,
|
|
1232
|
-
async (c, v) => ({ stats_reset: await getStatsReset(c, v) }));
|
|
1233
|
-
|
|
1234
|
-
/** Generate H004 report - Redundant indexes */
|
|
1235
|
-
export const generateH004 = (client: Client, nodeName = "node-01") =>
|
|
1236
|
-
generateIndexReport(client, nodeName, "H004", "Redundant indexes", "redundant_indexes", getRedundantIndexes);
|
|
1237
|
-
|
|
1238
|
-
/**
|
|
1239
|
-
* Generate D004 report - pg_stat_statements and pg_stat_kcache settings.
|
|
1240
|
-
*
|
|
1241
|
-
* Uses graceful degradation: extension queries are wrapped in try-catch
|
|
1242
|
-
* because extensions may not be installed. Errors are included in the
|
|
1243
|
-
* report output rather than failing the entire report.
|
|
1244
|
-
*/
|
|
1245
|
-
async function generateD004(client: Client, nodeName: string): Promise<Report> {
|
|
1246
|
-
const report = createBaseReport("D004", "pg_stat_statements and pg_stat_kcache settings", nodeName);
|
|
1247
|
-
const postgresVersion = await getPostgresVersion(client);
|
|
1248
|
-
const pgMajorVersion = parseInt(postgresVersion.server_major_ver, 10) || 16;
|
|
1249
|
-
const allSettings = await getSettings(client, pgMajorVersion);
|
|
1250
|
-
|
|
1251
|
-
// Filter settings related to pg_stat_statements and pg_stat_kcache
|
|
1252
|
-
const pgssSettings: Record<string, SettingInfo> = {};
|
|
1253
|
-
for (const [name, setting] of Object.entries(allSettings)) {
|
|
1254
|
-
if (name.startsWith("pg_stat_statements") || name.startsWith("pg_stat_kcache")) {
|
|
1255
|
-
pgssSettings[name] = setting;
|
|
1256
|
-
}
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
|
-
// Check pg_stat_statements extension
|
|
1260
|
-
let pgssAvailable = false;
|
|
1261
|
-
let pgssMetricsCount = 0;
|
|
1262
|
-
let pgssTotalCalls = 0;
|
|
1263
|
-
let pgssError: string | null = null;
|
|
1264
|
-
const pgssSampleQueries: Array<{ queryid: string; user: string; database: string; calls: number }> = [];
|
|
1265
|
-
|
|
1266
|
-
try {
|
|
1267
|
-
const extCheck = await client.query(
|
|
1268
|
-
"select 1 from pg_extension where extname = 'pg_stat_statements'"
|
|
1269
|
-
);
|
|
1270
|
-
if (extCheck.rows.length > 0) {
|
|
1271
|
-
pgssAvailable = true;
|
|
1272
|
-
const statsResult = await client.query(`
|
|
1273
|
-
select count(*) as cnt, coalesce(sum(calls), 0) as total_calls
|
|
1274
|
-
from pg_stat_statements
|
|
1275
|
-
`);
|
|
1276
|
-
pgssMetricsCount = parseInt(statsResult.rows[0]?.cnt || "0", 10);
|
|
1277
|
-
pgssTotalCalls = parseInt(statsResult.rows[0]?.total_calls || "0", 10);
|
|
1278
|
-
|
|
1279
|
-
// Get sample queries (top 5 by calls)
|
|
1280
|
-
const sampleResult = await client.query(`
|
|
1281
|
-
select
|
|
1282
|
-
queryid::text as queryid,
|
|
1283
|
-
coalesce(usename, 'unknown') as "user",
|
|
1284
|
-
coalesce(datname, 'unknown') as database,
|
|
1285
|
-
calls
|
|
1286
|
-
from pg_stat_statements s
|
|
1287
|
-
left join pg_database d on s.dbid = d.oid
|
|
1288
|
-
left join pg_user u on s.userid = u.usesysid
|
|
1289
|
-
order by calls desc
|
|
1290
|
-
limit 5
|
|
1291
|
-
`);
|
|
1292
|
-
for (const row of sampleResult.rows) {
|
|
1293
|
-
pgssSampleQueries.push({
|
|
1294
|
-
queryid: row.queryid,
|
|
1295
|
-
user: row.user,
|
|
1296
|
-
database: row.database,
|
|
1297
|
-
calls: parseInt(row.calls, 10),
|
|
1298
|
-
});
|
|
1299
|
-
}
|
|
1300
|
-
}
|
|
1301
|
-
} catch (err) {
|
|
1302
|
-
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1303
|
-
console.error(`[D004] Error querying pg_stat_statements: ${errorMsg}`);
|
|
1304
|
-
pgssError = errorMsg;
|
|
1305
|
-
}
|
|
1306
|
-
|
|
1307
|
-
// Check pg_stat_kcache extension
|
|
1308
|
-
let kcacheAvailable = false;
|
|
1309
|
-
let kcacheMetricsCount = 0;
|
|
1310
|
-
let kcacheTotalExecTime = 0;
|
|
1311
|
-
let kcacheTotalUserTime = 0;
|
|
1312
|
-
let kcacheTotalSystemTime = 0;
|
|
1313
|
-
let kcacheError: string | null = null;
|
|
1314
|
-
const kcacheSampleQueries: Array<{ queryid: string; user: string; exec_total_time: number }> = [];
|
|
1315
|
-
|
|
1316
|
-
try {
|
|
1317
|
-
const extCheck = await client.query(
|
|
1318
|
-
"select 1 from pg_extension where extname = 'pg_stat_kcache'"
|
|
1319
|
-
);
|
|
1320
|
-
if (extCheck.rows.length > 0) {
|
|
1321
|
-
kcacheAvailable = true;
|
|
1322
|
-
const statsResult = await client.query(`
|
|
1323
|
-
select
|
|
1324
|
-
count(*) as cnt,
|
|
1325
|
-
coalesce(sum(exec_user_time + exec_system_time), 0) as total_exec_time,
|
|
1326
|
-
coalesce(sum(exec_user_time), 0) as total_user_time,
|
|
1327
|
-
coalesce(sum(exec_system_time), 0) as total_system_time
|
|
1328
|
-
from pg_stat_kcache
|
|
1329
|
-
`);
|
|
1330
|
-
kcacheMetricsCount = parseInt(statsResult.rows[0]?.cnt || "0", 10);
|
|
1331
|
-
kcacheTotalExecTime = parseFloat(statsResult.rows[0]?.total_exec_time || "0");
|
|
1332
|
-
kcacheTotalUserTime = parseFloat(statsResult.rows[0]?.total_user_time || "0");
|
|
1333
|
-
kcacheTotalSystemTime = parseFloat(statsResult.rows[0]?.total_system_time || "0");
|
|
1334
|
-
|
|
1335
|
-
// Get sample queries (top 5 by exec time)
|
|
1336
|
-
const sampleResult = await client.query(`
|
|
1337
|
-
select
|
|
1338
|
-
queryid::text as queryid,
|
|
1339
|
-
coalesce(usename, 'unknown') as "user",
|
|
1340
|
-
(exec_user_time + exec_system_time) as exec_total_time
|
|
1341
|
-
from pg_stat_kcache k
|
|
1342
|
-
left join pg_user u on k.userid = u.usesysid
|
|
1343
|
-
order by (exec_user_time + exec_system_time) desc
|
|
1344
|
-
limit 5
|
|
1345
|
-
`);
|
|
1346
|
-
for (const row of sampleResult.rows) {
|
|
1347
|
-
kcacheSampleQueries.push({
|
|
1348
|
-
queryid: row.queryid,
|
|
1349
|
-
user: row.user,
|
|
1350
|
-
exec_total_time: parseFloat(row.exec_total_time),
|
|
1351
|
-
});
|
|
1352
|
-
}
|
|
1353
|
-
}
|
|
1354
|
-
} catch (err) {
|
|
1355
|
-
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1356
|
-
console.error(`[D004] Error querying pg_stat_kcache: ${errorMsg}`);
|
|
1357
|
-
kcacheError = errorMsg;
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
report.results[nodeName] = {
|
|
1361
|
-
data: {
|
|
1362
|
-
settings: pgssSettings,
|
|
1363
|
-
pg_stat_statements_status: {
|
|
1364
|
-
extension_available: pgssAvailable,
|
|
1365
|
-
metrics_count: pgssMetricsCount,
|
|
1366
|
-
total_calls: pgssTotalCalls,
|
|
1367
|
-
sample_queries: pgssSampleQueries,
|
|
1368
|
-
...(pgssError && { error: pgssError }),
|
|
1369
|
-
},
|
|
1370
|
-
pg_stat_kcache_status: {
|
|
1371
|
-
extension_available: kcacheAvailable,
|
|
1372
|
-
metrics_count: kcacheMetricsCount,
|
|
1373
|
-
total_exec_time: kcacheTotalExecTime,
|
|
1374
|
-
total_user_time: kcacheTotalUserTime,
|
|
1375
|
-
total_system_time: kcacheTotalSystemTime,
|
|
1376
|
-
sample_queries: kcacheSampleQueries,
|
|
1377
|
-
...(kcacheError && { error: kcacheError }),
|
|
1378
|
-
},
|
|
1379
|
-
},
|
|
1380
|
-
postgres_version: postgresVersion,
|
|
1381
|
-
};
|
|
1382
|
-
|
|
1383
|
-
return report;
|
|
1384
|
-
}
|
|
1385
|
-
|
|
1386
|
-
/**
|
|
1387
|
-
* Generate D001 report - Logging settings
|
|
1388
|
-
*
|
|
1389
|
-
* Collects all PostgreSQL logging-related settings including:
|
|
1390
|
-
* - Log destination and collector settings
|
|
1391
|
-
* - Log file rotation and naming
|
|
1392
|
-
* - Log verbosity and filtering
|
|
1393
|
-
* - Statement and duration logging
|
|
1394
|
-
*/
|
|
1395
|
-
async function generateD001(client: Client, nodeName: string): Promise<Report> {
|
|
1396
|
-
const report = createBaseReport("D001", "Logging settings", nodeName);
|
|
1397
|
-
const postgresVersion = await getPostgresVersion(client);
|
|
1398
|
-
const pgMajorVersion = parseInt(postgresVersion.server_major_ver, 10) || 16;
|
|
1399
|
-
const allSettings = await getSettings(client, pgMajorVersion);
|
|
1400
|
-
|
|
1401
|
-
// Filter logging-related settings (log_* and logging_*)
|
|
1402
|
-
const loggingSettings: Record<string, SettingInfo> = {};
|
|
1403
|
-
for (const [name, setting] of Object.entries(allSettings)) {
|
|
1404
|
-
if (name.startsWith("log_") || name.startsWith("logging_")) {
|
|
1405
|
-
loggingSettings[name] = setting;
|
|
1406
|
-
}
|
|
1407
|
-
}
|
|
1408
|
-
|
|
1409
|
-
report.results[nodeName] = {
|
|
1410
|
-
data: loggingSettings,
|
|
1411
|
-
postgres_version: postgresVersion,
|
|
1412
|
-
};
|
|
1413
|
-
|
|
1414
|
-
return report;
|
|
1415
|
-
}
|
|
1416
|
-
|
|
1417
|
-
/**
|
|
1418
|
-
* Generate F001 report - Autovacuum: current settings
|
|
1419
|
-
*/
|
|
1420
|
-
async function generateF001(client: Client, nodeName: string): Promise<Report> {
|
|
1421
|
-
const report = createBaseReport("F001", "Autovacuum: current settings", nodeName);
|
|
1422
|
-
const postgresVersion = await getPostgresVersion(client);
|
|
1423
|
-
const pgMajorVersion = parseInt(postgresVersion.server_major_ver, 10) || 16;
|
|
1424
|
-
const allSettings = await getSettings(client, pgMajorVersion);
|
|
1425
|
-
|
|
1426
|
-
// Filter autovacuum-related settings
|
|
1427
|
-
const autovacuumSettings: Record<string, SettingInfo> = {};
|
|
1428
|
-
for (const [name, setting] of Object.entries(allSettings)) {
|
|
1429
|
-
if (name.includes("autovacuum") || name.includes("vacuum")) {
|
|
1430
|
-
autovacuumSettings[name] = setting;
|
|
1431
|
-
}
|
|
1432
|
-
}
|
|
1433
|
-
|
|
1434
|
-
report.results[nodeName] = {
|
|
1435
|
-
data: autovacuumSettings,
|
|
1436
|
-
postgres_version: postgresVersion,
|
|
1437
|
-
};
|
|
1438
|
-
|
|
1439
|
-
return report;
|
|
1440
|
-
}
|
|
1441
|
-
|
|
1442
|
-
/**
|
|
1443
|
-
* Generate F003 report - Autovacuum: dead tuples
|
|
1444
|
-
*
|
|
1445
|
-
* Reads per-table dead-tuple counters from pg_stat_user_tables and per-table
|
|
1446
|
-
* autovacuum overrides from pg_class.reloptions. Flags tables where dead
|
|
1447
|
-
* tuples are high both in absolute terms and relative to live tuples, and
|
|
1448
|
-
* tables where autovacuum is disabled per-table (a classic footgun).
|
|
1449
|
-
*
|
|
1450
|
-
* Unlike F004/F005 (statistical bloat estimators), this check sees dead
|
|
1451
|
-
* tuples that have never been vacuumed.
|
|
1452
|
-
* SQL loaded from config/pgwatch-prometheus/metrics.yml (pg_dead_tuples metric).
|
|
1453
|
-
*/
|
|
1454
|
-
async function generateF003(client: Client, nodeName: string): Promise<Report> {
|
|
1455
|
-
const report = createBaseReport("F003", "Autovacuum: dead tuples", nodeName);
|
|
1456
|
-
const postgresVersion = await getPostgresVersion(client);
|
|
1457
|
-
const pgMajorVersion = parseInt(postgresVersion.server_major_ver, 10) || 16;
|
|
1458
|
-
|
|
1459
|
-
const tables = await getDeadTuples(client, pgMajorVersion);
|
|
1460
|
-
const { datname: dbName, size_bytes: dbSizeBytes } = await getCurrentDatabaseInfo(client, pgMajorVersion);
|
|
1461
|
-
|
|
1462
|
-
const flaggedCount = tables.filter((t) => t.exceeds_dead_tuple_thresholds).length;
|
|
1463
|
-
const autovacuumDisabledCount = tables.filter((t) => t.autovacuum_disabled).length;
|
|
1464
|
-
const autovacuumDisabledFlaggedCount = tables.filter((t) => t.autovacuum_disabled_flagged).length;
|
|
1465
|
-
const totalDeadTuples = tables.reduce((sum, t) => sum + t.n_dead_tup, 0);
|
|
1466
|
-
const { conclusions, recommendations } = buildDeadTuplesConclusions(tables);
|
|
1467
|
-
|
|
1468
|
-
const dbEntry = {
|
|
1469
|
-
dead_tuples_tables: tables,
|
|
1470
|
-
total_count: tables.length,
|
|
1471
|
-
flagged_count: flaggedCount,
|
|
1472
|
-
autovacuum_disabled_count: autovacuumDisabledCount,
|
|
1473
|
-
autovacuum_disabled_flagged_count: autovacuumDisabledFlaggedCount,
|
|
1474
|
-
total_dead_tuples: totalDeadTuples,
|
|
1475
|
-
thresholds: {
|
|
1476
|
-
dead_tuples_min: F003_DEAD_TUPLES_MIN,
|
|
1477
|
-
dead_pct_min: F003_DEAD_PCT_MIN,
|
|
1478
|
-
autovacuum_disabled_min_rows: F003_AUTOVACUUM_DISABLED_MIN_ROWS,
|
|
1479
|
-
},
|
|
1480
|
-
conclusions,
|
|
1481
|
-
recommendations,
|
|
1482
|
-
database_size_bytes: dbSizeBytes,
|
|
1483
|
-
database_size_pretty: formatBytes(dbSizeBytes),
|
|
1484
|
-
};
|
|
1485
|
-
|
|
1486
|
-
report.results[nodeName] = {
|
|
1487
|
-
data: { [dbName]: dbEntry },
|
|
1488
|
-
postgres_version: postgresVersion,
|
|
1489
|
-
};
|
|
1490
|
-
|
|
1491
|
-
return report;
|
|
1492
|
-
}
|
|
1493
|
-
|
|
1494
|
-
/**
|
|
1495
|
-
* Generate F004 report - Autovacuum: heap bloat (estimated)
|
|
1496
|
-
*
|
|
1497
|
-
* Estimates table bloat based on statistical analysis of table pages vs expected pages.
|
|
1498
|
-
* Uses pg_stats for column statistics to estimate row sizes.
|
|
1499
|
-
* SQL loaded from config/pgwatch-prometheus/metrics.yml (pg_table_bloat metric).
|
|
1500
|
-
*/
|
|
1501
|
-
type BloatCheckReason = "missing_schema" | "missing_view" | "missing_grant" | "query_error";
|
|
1502
|
-
|
|
1503
|
-
interface BloatCheckStatus {
|
|
1504
|
-
ok: boolean;
|
|
1505
|
-
reason: BloatCheckReason | null;
|
|
1506
|
-
error: string | null;
|
|
1507
|
-
}
|
|
1508
|
-
|
|
1509
|
-
function bloatErrorStatus(err: unknown): BloatCheckStatus {
|
|
1510
|
-
const error = err instanceof Error ? err.message : String(err);
|
|
1511
|
-
const code = typeof err === "object" && err !== null && "code" in err
|
|
1512
|
-
? String((err as { code?: unknown }).code || "")
|
|
1513
|
-
: "";
|
|
1514
|
-
const normalized = error.toLowerCase();
|
|
1515
|
-
|
|
1516
|
-
let reason: BloatCheckReason = "query_error";
|
|
1517
|
-
if (code === "3F000" || normalized.includes('schema "postgres_ai" does not exist')) {
|
|
1518
|
-
reason = "missing_schema";
|
|
1519
|
-
} else if (code === "42P01" || normalized.includes('relation "postgres_ai.pg_statistic" does not exist')) {
|
|
1520
|
-
reason = "missing_view";
|
|
1521
|
-
} else if (code === "42501" || normalized.includes("permission denied")) {
|
|
1522
|
-
reason = "missing_grant";
|
|
1523
|
-
}
|
|
1524
|
-
|
|
1525
|
-
return { ok: false, reason, error };
|
|
1526
|
-
}
|
|
1527
|
-
|
|
1528
|
-
async function getBloatCheckStatus(client: Client): Promise<BloatCheckStatus> {
|
|
1529
|
-
try {
|
|
1530
|
-
const result = await client.query(`
|
|
1531
|
-
select
|
|
1532
|
-
to_regnamespace('postgres_ai') is not null as schema_exists,
|
|
1533
|
-
case
|
|
1534
|
-
when to_regnamespace('postgres_ai') is null then false
|
|
1535
|
-
else has_schema_privilege(current_user, 'postgres_ai', 'USAGE')
|
|
1536
|
-
end as schema_usage,
|
|
1537
|
-
case
|
|
1538
|
-
when to_regnamespace('postgres_ai') is null then false
|
|
1539
|
-
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then false
|
|
1540
|
-
else to_regclass('postgres_ai.pg_statistic') is not null
|
|
1541
|
-
end as view_exists,
|
|
1542
|
-
case
|
|
1543
|
-
when to_regnamespace('postgres_ai') is null then false
|
|
1544
|
-
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then false
|
|
1545
|
-
when to_regclass('postgres_ai.pg_statistic') is null then false
|
|
1546
|
-
else has_table_privilege(current_user, 'postgres_ai.pg_statistic', 'SELECT')
|
|
1547
|
-
end as view_select
|
|
1548
|
-
`);
|
|
1549
|
-
const capability = result.rows[0] || {};
|
|
1550
|
-
|
|
1551
|
-
if (!capability.schema_exists) {
|
|
1552
|
-
return { ok: false, reason: "missing_schema", error: 'schema "postgres_ai" does not exist' };
|
|
1553
|
-
}
|
|
1554
|
-
if (!capability.schema_usage) {
|
|
1555
|
-
return { ok: false, reason: "missing_grant", error: "permission denied for schema postgres_ai" };
|
|
1556
|
-
}
|
|
1557
|
-
if (!capability.view_exists) {
|
|
1558
|
-
return { ok: false, reason: "missing_view", error: 'relation "postgres_ai.pg_statistic" does not exist' };
|
|
1559
|
-
}
|
|
1560
|
-
if (!capability.view_select) {
|
|
1561
|
-
return { ok: false, reason: "missing_grant", error: "permission denied for relation postgres_ai.pg_statistic" };
|
|
1562
|
-
}
|
|
1563
|
-
return { ok: true, reason: null, error: null };
|
|
1564
|
-
} catch (err) {
|
|
1565
|
-
return bloatErrorStatus(err);
|
|
1566
|
-
}
|
|
1567
|
-
}
|
|
1568
|
-
|
|
1569
|
-
async function generateF004(client: Client, nodeName: string): Promise<Report> {
|
|
1570
|
-
const report = createBaseReport("F004", "Autovacuum: heap bloat (estimated)", nodeName);
|
|
1571
|
-
const postgresVersion = await getPostgresVersion(client);
|
|
1572
|
-
const pgMajorVersion = parseInt(postgresVersion.server_major_ver, 10);
|
|
1573
|
-
|
|
1574
|
-
interface BloatedTable {
|
|
1575
|
-
schema_name: string;
|
|
1576
|
-
table_name: string;
|
|
1577
|
-
real_size: number;
|
|
1578
|
-
extra_size: number;
|
|
1579
|
-
extra_pct: number;
|
|
1580
|
-
bloat_size: number;
|
|
1581
|
-
bloat_pct: number;
|
|
1582
|
-
fillfactor: number;
|
|
1583
|
-
last_vacuum: string | null;
|
|
1584
|
-
last_vacuum_epoch: number;
|
|
1585
|
-
real_size_pretty: string;
|
|
1586
|
-
extra_size_pretty: string;
|
|
1587
|
-
bloat_size_pretty: string;
|
|
1588
|
-
}
|
|
1589
|
-
|
|
1590
|
-
let bloatedTables: BloatedTable[] = [];
|
|
1591
|
-
let status = await getBloatCheckStatus(client);
|
|
1592
|
-
|
|
1593
|
-
try {
|
|
1594
|
-
if (!status.ok) throw Object.assign(new Error(status.error || "Bloat prerequisites unavailable"), {
|
|
1595
|
-
code: status.reason === "missing_schema" ? "3F000"
|
|
1596
|
-
: status.reason === "missing_view" ? "42P01"
|
|
1597
|
-
: status.reason === "missing_grant" ? "42501"
|
|
1598
|
-
: undefined,
|
|
1599
|
-
});
|
|
1600
|
-
// Get bloat data
|
|
1601
|
-
const sql = getMetricSql(METRIC_NAMES.F004, pgMajorVersion);
|
|
1602
|
-
const bloatResult = await client.query(sql);
|
|
1603
|
-
|
|
1604
|
-
// Get vacuum stats for all tables
|
|
1605
|
-
const vacuumStatsResult = await client.query(`
|
|
1606
|
-
SELECT schemaname, relname, last_vacuum, last_autovacuum
|
|
1607
|
-
FROM pg_stat_user_tables
|
|
1608
|
-
`);
|
|
1609
|
-
const vacuumStats = new Map<string, { last_vacuum: string | null; last_vacuum_epoch: number }>();
|
|
1610
|
-
for (const row of vacuumStatsResult.rows) {
|
|
1611
|
-
const key = `${row.schemaname}.${row.relname}`;
|
|
1612
|
-
// Use last_autovacuum if last_vacuum is null, otherwise prefer last_vacuum
|
|
1613
|
-
const vacuumTime = row.last_vacuum || row.last_autovacuum;
|
|
1614
|
-
vacuumStats.set(key, {
|
|
1615
|
-
last_vacuum: vacuumTime ? new Date(vacuumTime).toISOString() : null,
|
|
1616
|
-
last_vacuum_epoch: vacuumTime ? Math.floor(new Date(vacuumTime).getTime() / 1000) : 0,
|
|
1617
|
-
});
|
|
1618
|
-
}
|
|
1619
|
-
|
|
1620
|
-
bloatedTables = bloatResult.rows.map((row) => {
|
|
1621
|
-
const t = transformMetricRow(row);
|
|
1622
|
-
const schemaName = String(t.schemaname || "");
|
|
1623
|
-
const tableName = String(t.tblname || "");
|
|
1624
|
-
const realSizeBytes = Math.round((parseFloat(String(t.real_size_mib)) || 0) * 1024 * 1024);
|
|
1625
|
-
const extraSize = parseInt(String(t.extra_size || 0), 10);
|
|
1626
|
-
const bloatSize = parseInt(String(t.bloat_size || 0), 10);
|
|
1627
|
-
|
|
1628
|
-
const vacuumInfo = vacuumStats.get(`${schemaName}.${tableName}`) || {
|
|
1629
|
-
last_vacuum: null,
|
|
1630
|
-
last_vacuum_epoch: 0,
|
|
1631
|
-
};
|
|
1632
|
-
|
|
1633
|
-
return {
|
|
1634
|
-
schema_name: schemaName,
|
|
1635
|
-
table_name: tableName,
|
|
1636
|
-
real_size: realSizeBytes,
|
|
1637
|
-
extra_size: extraSize,
|
|
1638
|
-
extra_pct: parseFloat(String(t.extra_pct)) || 0,
|
|
1639
|
-
bloat_size: bloatSize,
|
|
1640
|
-
bloat_pct: parseFloat(String(t.bloat_pct)) || 0,
|
|
1641
|
-
fillfactor: parseInt(String(t.fillfactor || 100), 10),
|
|
1642
|
-
last_vacuum: vacuumInfo.last_vacuum,
|
|
1643
|
-
last_vacuum_epoch: vacuumInfo.last_vacuum_epoch,
|
|
1644
|
-
real_size_pretty: formatBytes(realSizeBytes),
|
|
1645
|
-
extra_size_pretty: formatBytes(extraSize),
|
|
1646
|
-
bloat_size_pretty: formatBytes(bloatSize),
|
|
1647
|
-
};
|
|
1648
|
-
});
|
|
1649
|
-
} catch (err) {
|
|
1650
|
-
status = bloatErrorStatus(err);
|
|
1651
|
-
const errorMsg = status.error || "Unknown error";
|
|
1652
|
-
console.error(`[F004] Error estimating table bloat: ${errorMsg}`);
|
|
1653
|
-
if (errorMsg.includes("postgres_ai.")) {
|
|
1654
|
-
console.error(` Hint: Run "postgresai prepare-db <connection>" to create required objects.`);
|
|
1655
|
-
}
|
|
1656
|
-
}
|
|
1657
|
-
|
|
1658
|
-
// Get database info
|
|
1659
|
-
const { datname: dbName, size_bytes: dbSizeBytes } = await getCurrentDatabaseInfo(client, pgMajorVersion);
|
|
1660
|
-
|
|
1661
|
-
// Calculate totals
|
|
1662
|
-
const totalCount = bloatedTables.length;
|
|
1663
|
-
const totalBloatSizeBytes = bloatedTables.reduce((sum, t) => sum + t.bloat_size, 0);
|
|
1664
|
-
|
|
1665
|
-
const dbEntry = {
|
|
1666
|
-
status,
|
|
1667
|
-
bloated_tables: bloatedTables,
|
|
1668
|
-
total_count: totalCount,
|
|
1669
|
-
total_bloat_size_bytes: totalBloatSizeBytes,
|
|
1670
|
-
total_bloat_size_pretty: formatBytes(totalBloatSizeBytes),
|
|
1671
|
-
database_size_bytes: dbSizeBytes,
|
|
1672
|
-
database_size_pretty: formatBytes(dbSizeBytes),
|
|
1673
|
-
};
|
|
1674
|
-
|
|
1675
|
-
report.results[nodeName] = {
|
|
1676
|
-
data: { [dbName]: dbEntry },
|
|
1677
|
-
postgres_version: postgresVersion,
|
|
1678
|
-
};
|
|
1679
|
-
|
|
1680
|
-
return report;
|
|
1681
|
-
}
|
|
1682
|
-
|
|
1683
|
-
/**
|
|
1684
|
-
* Generate F005 report - Autovacuum: index bloat (estimated)
|
|
1685
|
-
*
|
|
1686
|
-
* Estimates B-tree index bloat based on statistical analysis of index pages vs expected pages.
|
|
1687
|
-
* SQL loaded from config/pgwatch-prometheus/metrics.yml (pg_btree_bloat metric).
|
|
1688
|
-
*/
|
|
1689
|
-
async function generateF005(client: Client, nodeName: string): Promise<Report> {
|
|
1690
|
-
const report = createBaseReport("F005", "Autovacuum: index bloat (estimated)", nodeName);
|
|
1691
|
-
const postgresVersion = await getPostgresVersion(client);
|
|
1692
|
-
const pgMajorVersion = parseInt(postgresVersion.server_major_ver, 10);
|
|
1693
|
-
|
|
1694
|
-
interface BloatedIndex {
|
|
1695
|
-
schema_name: string;
|
|
1696
|
-
table_name: string;
|
|
1697
|
-
index_name: string;
|
|
1698
|
-
real_size: number;
|
|
1699
|
-
table_size: number;
|
|
1700
|
-
extra_size: number;
|
|
1701
|
-
extra_pct: number;
|
|
1702
|
-
bloat_size: number;
|
|
1703
|
-
bloat_pct: number;
|
|
1704
|
-
fillfactor: number;
|
|
1705
|
-
last_vacuum: string | null;
|
|
1706
|
-
last_vacuum_epoch: number;
|
|
1707
|
-
real_size_pretty: string;
|
|
1708
|
-
table_size_pretty: string;
|
|
1709
|
-
extra_size_pretty: string;
|
|
1710
|
-
bloat_size_pretty: string;
|
|
1711
|
-
}
|
|
1712
|
-
|
|
1713
|
-
let bloatedIndexes: BloatedIndex[] = [];
|
|
1714
|
-
let status = await getBloatCheckStatus(client);
|
|
1715
|
-
|
|
1716
|
-
try {
|
|
1717
|
-
if (!status.ok) throw Object.assign(new Error(status.error || "Bloat prerequisites unavailable"), {
|
|
1718
|
-
code: status.reason === "missing_schema" ? "3F000"
|
|
1719
|
-
: status.reason === "missing_view" ? "42P01"
|
|
1720
|
-
: status.reason === "missing_grant" ? "42501"
|
|
1721
|
-
: undefined,
|
|
1722
|
-
});
|
|
1723
|
-
// Get bloat data
|
|
1724
|
-
const sql = getMetricSql(METRIC_NAMES.F005, pgMajorVersion);
|
|
1725
|
-
const bloatResult = await client.query(sql);
|
|
1726
|
-
|
|
1727
|
-
// Get vacuum stats for all tables (indexes inherit vacuum time from their table)
|
|
1728
|
-
const vacuumStatsResult = await client.query(`
|
|
1729
|
-
SELECT schemaname, relname, last_vacuum, last_autovacuum
|
|
1730
|
-
FROM pg_stat_user_tables
|
|
1731
|
-
`);
|
|
1732
|
-
const vacuumStats = new Map<string, { last_vacuum: string | null; last_vacuum_epoch: number }>();
|
|
1733
|
-
for (const row of vacuumStatsResult.rows) {
|
|
1734
|
-
const key = `${row.schemaname}.${row.relname}`;
|
|
1735
|
-
const vacuumTime = row.last_vacuum || row.last_autovacuum;
|
|
1736
|
-
vacuumStats.set(key, {
|
|
1737
|
-
last_vacuum: vacuumTime ? new Date(vacuumTime).toISOString() : null,
|
|
1738
|
-
last_vacuum_epoch: vacuumTime ? Math.floor(new Date(vacuumTime).getTime() / 1000) : 0,
|
|
1739
|
-
});
|
|
1740
|
-
}
|
|
1741
|
-
|
|
1742
|
-
bloatedIndexes = bloatResult.rows.map((row) => {
|
|
1743
|
-
const t = transformMetricRow(row);
|
|
1744
|
-
const schemaName = String(t.schemaname || "");
|
|
1745
|
-
const tableName = String(t.tblname || "");
|
|
1746
|
-
const indexName = String(t.idxname || "");
|
|
1747
|
-
const realSizeBytes = Math.round((parseFloat(String(t.real_size_mib)) || 0) * 1024 * 1024);
|
|
1748
|
-
const tableSizeBytes = Math.round((parseFloat(String(t.table_size_mib)) || 0) * 1024 * 1024);
|
|
1749
|
-
const extraSize = parseInt(String(t.extra_size || 0), 10);
|
|
1750
|
-
const bloatSize = parseInt(String(t.bloat_size || 0), 10);
|
|
1751
|
-
|
|
1752
|
-
const vacuumInfo = vacuumStats.get(`${schemaName}.${tableName}`) || {
|
|
1753
|
-
last_vacuum: null,
|
|
1754
|
-
last_vacuum_epoch: 0,
|
|
1755
|
-
};
|
|
1756
|
-
|
|
1757
|
-
return {
|
|
1758
|
-
schema_name: schemaName,
|
|
1759
|
-
table_name: tableName,
|
|
1760
|
-
index_name: indexName,
|
|
1761
|
-
real_size: realSizeBytes,
|
|
1762
|
-
table_size: tableSizeBytes,
|
|
1763
|
-
extra_size: extraSize,
|
|
1764
|
-
extra_pct: parseFloat(String(t.extra_pct)) || 0,
|
|
1765
|
-
bloat_size: bloatSize,
|
|
1766
|
-
bloat_pct: parseFloat(String(t.bloat_pct)) || 0,
|
|
1767
|
-
fillfactor: parseInt(String(t.fillfactor || 90), 10),
|
|
1768
|
-
last_vacuum: vacuumInfo.last_vacuum,
|
|
1769
|
-
last_vacuum_epoch: vacuumInfo.last_vacuum_epoch,
|
|
1770
|
-
real_size_pretty: formatBytes(realSizeBytes),
|
|
1771
|
-
table_size_pretty: formatBytes(tableSizeBytes),
|
|
1772
|
-
extra_size_pretty: formatBytes(extraSize),
|
|
1773
|
-
bloat_size_pretty: formatBytes(bloatSize),
|
|
1774
|
-
};
|
|
1775
|
-
});
|
|
1776
|
-
} catch (err) {
|
|
1777
|
-
status = bloatErrorStatus(err);
|
|
1778
|
-
const errorMsg = status.error || "Unknown error";
|
|
1779
|
-
console.error(`[F005] Error estimating index bloat: ${errorMsg}`);
|
|
1780
|
-
if (errorMsg.includes("postgres_ai.")) {
|
|
1781
|
-
console.error(` Hint: Run "postgresai prepare-db <connection>" to create required objects.`);
|
|
1782
|
-
}
|
|
1783
|
-
}
|
|
1784
|
-
|
|
1785
|
-
// Get database info
|
|
1786
|
-
const { datname: dbName, size_bytes: dbSizeBytes } = await getCurrentDatabaseInfo(client, pgMajorVersion);
|
|
1787
|
-
|
|
1788
|
-
// Calculate totals
|
|
1789
|
-
const totalCount = bloatedIndexes.length;
|
|
1790
|
-
const totalBloatSizeBytes = bloatedIndexes.reduce((sum, idx) => sum + idx.bloat_size, 0);
|
|
1791
|
-
|
|
1792
|
-
const dbEntry = {
|
|
1793
|
-
status,
|
|
1794
|
-
bloated_indexes: bloatedIndexes,
|
|
1795
|
-
total_count: totalCount,
|
|
1796
|
-
total_bloat_size_bytes: totalBloatSizeBytes,
|
|
1797
|
-
total_bloat_size_pretty: formatBytes(totalBloatSizeBytes),
|
|
1798
|
-
database_size_bytes: dbSizeBytes,
|
|
1799
|
-
database_size_pretty: formatBytes(dbSizeBytes),
|
|
1800
|
-
};
|
|
1801
|
-
|
|
1802
|
-
report.results[nodeName] = {
|
|
1803
|
-
data: { [dbName]: dbEntry },
|
|
1804
|
-
postgres_version: postgresVersion,
|
|
1805
|
-
};
|
|
1806
|
-
|
|
1807
|
-
return report;
|
|
1808
|
-
}
|
|
1809
|
-
|
|
1810
|
-
/**
|
|
1811
|
-
* Generate G001 report - Memory-related settings
|
|
1812
|
-
*/
|
|
1813
|
-
async function generateG001(client: Client, nodeName: string): Promise<Report> {
|
|
1814
|
-
const report = createBaseReport("G001", "Memory-related settings", nodeName);
|
|
1815
|
-
const postgresVersion = await getPostgresVersion(client);
|
|
1816
|
-
const pgMajorVersion = parseInt(postgresVersion.server_major_ver, 10) || 16;
|
|
1817
|
-
const allSettings = await getSettings(client, pgMajorVersion);
|
|
1818
|
-
|
|
1819
|
-
// Memory-related setting names
|
|
1820
|
-
const memorySettingNames = [
|
|
1821
|
-
"shared_buffers",
|
|
1822
|
-
"work_mem",
|
|
1823
|
-
"maintenance_work_mem",
|
|
1824
|
-
"effective_cache_size",
|
|
1825
|
-
"wal_buffers",
|
|
1826
|
-
"temp_buffers",
|
|
1827
|
-
"max_connections",
|
|
1828
|
-
"autovacuum_work_mem",
|
|
1829
|
-
"hash_mem_multiplier",
|
|
1830
|
-
"logical_decoding_work_mem",
|
|
1831
|
-
"max_stack_depth",
|
|
1832
|
-
"max_prepared_transactions",
|
|
1833
|
-
"max_locks_per_transaction",
|
|
1834
|
-
"max_pred_locks_per_transaction",
|
|
1835
|
-
];
|
|
1836
|
-
|
|
1837
|
-
const memorySettings: Record<string, SettingInfo> = {};
|
|
1838
|
-
for (const name of memorySettingNames) {
|
|
1839
|
-
if (allSettings[name]) {
|
|
1840
|
-
memorySettings[name] = allSettings[name];
|
|
1841
|
-
}
|
|
1842
|
-
}
|
|
1843
|
-
|
|
1844
|
-
// Calculate memory usage estimates
|
|
1845
|
-
interface MemoryUsage {
|
|
1846
|
-
shared_buffers_bytes: number;
|
|
1847
|
-
shared_buffers_pretty: string;
|
|
1848
|
-
wal_buffers_bytes: number;
|
|
1849
|
-
wal_buffers_pretty: string;
|
|
1850
|
-
shared_memory_total_bytes: number;
|
|
1851
|
-
shared_memory_total_pretty: string;
|
|
1852
|
-
work_mem_per_connection_bytes: number;
|
|
1853
|
-
work_mem_per_connection_pretty: string;
|
|
1854
|
-
max_work_mem_usage_bytes: number;
|
|
1855
|
-
max_work_mem_usage_pretty: string;
|
|
1856
|
-
maintenance_work_mem_bytes: number;
|
|
1857
|
-
maintenance_work_mem_pretty: string;
|
|
1858
|
-
effective_cache_size_bytes: number;
|
|
1859
|
-
effective_cache_size_pretty: string;
|
|
1860
|
-
}
|
|
1861
|
-
|
|
1862
|
-
let memoryUsage: MemoryUsage | Record<string, never> = {};
|
|
1863
|
-
let memoryError: string | null = null;
|
|
1864
|
-
|
|
1865
|
-
try {
|
|
1866
|
-
// Get actual byte values from PostgreSQL
|
|
1867
|
-
const memQuery = await client.query(`
|
|
1868
|
-
select
|
|
1869
|
-
pg_size_bytes(current_setting('shared_buffers')) as shared_buffers_bytes,
|
|
1870
|
-
pg_size_bytes(current_setting('wal_buffers')) as wal_buffers_bytes,
|
|
1871
|
-
pg_size_bytes(current_setting('work_mem')) as work_mem_bytes,
|
|
1872
|
-
pg_size_bytes(current_setting('maintenance_work_mem')) as maintenance_work_mem_bytes,
|
|
1873
|
-
pg_size_bytes(current_setting('effective_cache_size')) as effective_cache_size_bytes,
|
|
1874
|
-
current_setting('max_connections')::int as max_connections
|
|
1875
|
-
`);
|
|
1876
|
-
|
|
1877
|
-
if (memQuery.rows.length > 0) {
|
|
1878
|
-
const row = memQuery.rows[0];
|
|
1879
|
-
const sharedBuffersBytes = parseInt(row.shared_buffers_bytes, 10);
|
|
1880
|
-
const walBuffersBytes = parseInt(row.wal_buffers_bytes, 10);
|
|
1881
|
-
const workMemBytes = parseInt(row.work_mem_bytes, 10);
|
|
1882
|
-
const maintenanceWorkMemBytes = parseInt(row.maintenance_work_mem_bytes, 10);
|
|
1883
|
-
const effectiveCacheSizeBytes = parseInt(row.effective_cache_size_bytes, 10);
|
|
1884
|
-
const maxConnections = row.max_connections;
|
|
1885
|
-
|
|
1886
|
-
const sharedMemoryTotal = sharedBuffersBytes + walBuffersBytes;
|
|
1887
|
-
const maxWorkMemUsage = workMemBytes * maxConnections;
|
|
1888
|
-
|
|
1889
|
-
memoryUsage = {
|
|
1890
|
-
shared_buffers_bytes: sharedBuffersBytes,
|
|
1891
|
-
shared_buffers_pretty: formatBytes(sharedBuffersBytes),
|
|
1892
|
-
wal_buffers_bytes: walBuffersBytes,
|
|
1893
|
-
wal_buffers_pretty: formatBytes(walBuffersBytes),
|
|
1894
|
-
shared_memory_total_bytes: sharedMemoryTotal,
|
|
1895
|
-
shared_memory_total_pretty: formatBytes(sharedMemoryTotal),
|
|
1896
|
-
work_mem_per_connection_bytes: workMemBytes,
|
|
1897
|
-
work_mem_per_connection_pretty: formatBytes(workMemBytes),
|
|
1898
|
-
max_work_mem_usage_bytes: maxWorkMemUsage,
|
|
1899
|
-
max_work_mem_usage_pretty: formatBytes(maxWorkMemUsage),
|
|
1900
|
-
maintenance_work_mem_bytes: maintenanceWorkMemBytes,
|
|
1901
|
-
maintenance_work_mem_pretty: formatBytes(maintenanceWorkMemBytes),
|
|
1902
|
-
effective_cache_size_bytes: effectiveCacheSizeBytes,
|
|
1903
|
-
effective_cache_size_pretty: formatBytes(effectiveCacheSizeBytes),
|
|
1904
|
-
};
|
|
1905
|
-
}
|
|
1906
|
-
} catch (err) {
|
|
1907
|
-
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1908
|
-
console.error(`[G001] Error calculating memory usage: ${errorMsg}`);
|
|
1909
|
-
memoryError = errorMsg;
|
|
1910
|
-
}
|
|
1911
|
-
|
|
1912
|
-
report.results[nodeName] = {
|
|
1913
|
-
data: {
|
|
1914
|
-
settings: memorySettings,
|
|
1915
|
-
analysis: {
|
|
1916
|
-
estimated_total_memory_usage: memoryUsage,
|
|
1917
|
-
...(memoryError && { error: memoryError }),
|
|
1918
|
-
},
|
|
1919
|
-
},
|
|
1920
|
-
postgres_version: postgresVersion,
|
|
1921
|
-
};
|
|
1922
|
-
|
|
1923
|
-
return report;
|
|
1924
|
-
}
|
|
1925
|
-
|
|
1926
|
-
/**
|
|
1927
|
-
* Generate G003 report - Timeouts, locks, deadlocks
|
|
1928
|
-
*
|
|
1929
|
-
* Collects timeout and lock-related settings, plus deadlock statistics.
|
|
1930
|
-
*/
|
|
1931
|
-
async function generateG003(client: Client, nodeName: string): Promise<Report> {
|
|
1932
|
-
const report = createBaseReport("G003", "Timeouts, locks, deadlocks", nodeName);
|
|
1933
|
-
const postgresVersion = await getPostgresVersion(client);
|
|
1934
|
-
const pgMajorVersion = parseInt(postgresVersion.server_major_ver, 10) || 16;
|
|
1935
|
-
const allSettings = await getSettings(client, pgMajorVersion);
|
|
1936
|
-
|
|
1937
|
-
// Timeout and lock-related setting names
|
|
1938
|
-
const lockTimeoutSettingNames = [
|
|
1939
|
-
"lock_timeout",
|
|
1940
|
-
"statement_timeout",
|
|
1941
|
-
"idle_in_transaction_session_timeout",
|
|
1942
|
-
"idle_session_timeout",
|
|
1943
|
-
"deadlock_timeout",
|
|
1944
|
-
"max_locks_per_transaction",
|
|
1945
|
-
"max_pred_locks_per_transaction",
|
|
1946
|
-
"max_pred_locks_per_relation",
|
|
1947
|
-
"max_pred_locks_per_page",
|
|
1948
|
-
"log_lock_waits",
|
|
1949
|
-
"transaction_timeout",
|
|
1950
|
-
];
|
|
1951
|
-
|
|
1952
|
-
const lockSettings: Record<string, SettingInfo> = {};
|
|
1953
|
-
for (const name of lockTimeoutSettingNames) {
|
|
1954
|
-
if (allSettings[name]) {
|
|
1955
|
-
lockSettings[name] = allSettings[name];
|
|
1956
|
-
}
|
|
1957
|
-
}
|
|
1958
|
-
|
|
1959
|
-
// Get deadlock statistics from pg_stat_database
|
|
1960
|
-
let deadlockStats: {
|
|
1961
|
-
deadlocks: number;
|
|
1962
|
-
conflicts: number;
|
|
1963
|
-
stats_reset: string | null;
|
|
1964
|
-
} | null = null;
|
|
1965
|
-
let deadlockError: string | null = null;
|
|
1966
|
-
|
|
1967
|
-
try {
|
|
1968
|
-
const statsResult = await client.query(`
|
|
1969
|
-
select
|
|
1970
|
-
coalesce(sum(deadlocks), 0)::bigint as deadlocks,
|
|
1971
|
-
coalesce(sum(conflicts), 0)::bigint as conflicts,
|
|
1972
|
-
min(stats_reset)::text as stats_reset
|
|
1973
|
-
from pg_stat_database
|
|
1974
|
-
where datname = current_database()
|
|
1975
|
-
`);
|
|
1976
|
-
if (statsResult.rows.length > 0) {
|
|
1977
|
-
const row = statsResult.rows[0];
|
|
1978
|
-
deadlockStats = {
|
|
1979
|
-
deadlocks: parseInt(row.deadlocks, 10),
|
|
1980
|
-
conflicts: parseInt(row.conflicts, 10),
|
|
1981
|
-
stats_reset: row.stats_reset || null,
|
|
1982
|
-
};
|
|
1983
|
-
}
|
|
1984
|
-
} catch (err) {
|
|
1985
|
-
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1986
|
-
console.error(`[G003] Error querying deadlock stats: ${errorMsg}`);
|
|
1987
|
-
deadlockError = errorMsg;
|
|
1988
|
-
}
|
|
1989
|
-
|
|
1990
|
-
report.results[nodeName] = {
|
|
1991
|
-
data: {
|
|
1992
|
-
settings: lockSettings,
|
|
1993
|
-
deadlock_stats: deadlockStats,
|
|
1994
|
-
...(deadlockError && { deadlock_stats_error: deadlockError }),
|
|
1995
|
-
},
|
|
1996
|
-
postgres_version: postgresVersion,
|
|
1997
|
-
};
|
|
1998
|
-
|
|
1999
|
-
return report;
|
|
2000
|
-
}
|
|
2001
|
-
|
|
2002
|
-
/**
|
|
2003
|
-
* Get I/O statistics from pg_stat_io (PostgreSQL 16+).
|
|
2004
|
-
* Uses 'pg_stat_io' metric from metrics.yml.
|
|
2005
|
-
*
|
|
2006
|
-
* @param client - Connected PostgreSQL client
|
|
2007
|
-
* @param pgMajorVersion - PostgreSQL major version; defaults to 0 so omitted versions return unavailable
|
|
2008
|
-
* @param metricSqlOverride - Optional SQL override; empty or placeholder SQL returns [] without querying
|
|
2009
|
-
* @returns Array of I/O stats by backend type, or empty array if unavailable
|
|
2010
|
-
*/
|
|
2011
|
-
export async function getIOStatistics(
|
|
2012
|
-
client: Client,
|
|
2013
|
-
pgMajorVersion: number = 0,
|
|
2014
|
-
metricSqlOverride?: string
|
|
2015
|
-
): Promise<BackendIOStats[]> {
|
|
2016
|
-
// pg_stat_io requires PostgreSQL 16+
|
|
2017
|
-
if (pgMajorVersion < 16) {
|
|
2018
|
-
return [];
|
|
2019
|
-
}
|
|
2020
|
-
|
|
2021
|
-
try {
|
|
2022
|
-
const sql = metricSqlOverride ?? getMetricSql(METRIC_NAMES.I001, pgMajorVersion);
|
|
2023
|
-
// Skip if metric returns empty/placeholder SQL
|
|
2024
|
-
if (!sql || sql.trim().startsWith(";")) {
|
|
2025
|
-
return [];
|
|
2026
|
-
}
|
|
2027
|
-
|
|
2028
|
-
const result = await client.query(sql);
|
|
2029
|
-
return result.rows.map((row) => {
|
|
2030
|
-
const transformed = transformMetricRow(row);
|
|
2031
|
-
return {
|
|
2032
|
-
backend_type: String(transformed.backend_type || "unknown"),
|
|
2033
|
-
reads: parseInt(String(transformed.reads || 0), 10),
|
|
2034
|
-
read_bytes_mb: parseInt(String(transformed.read_bytes_mb || 0), 10),
|
|
2035
|
-
read_time_ms: parseInt(String(transformed.read_time_ms || 0), 10),
|
|
2036
|
-
writes: parseInt(String(transformed.writes || 0), 10),
|
|
2037
|
-
write_bytes_mb: parseInt(String(transformed.write_bytes_mb || 0), 10),
|
|
2038
|
-
write_time_ms: parseInt(String(transformed.write_time_ms || 0), 10),
|
|
2039
|
-
writebacks: parseInt(String(transformed.writebacks || 0), 10),
|
|
2040
|
-
writeback_bytes_mb: parseInt(String(transformed.writeback_bytes_mb || 0), 10),
|
|
2041
|
-
writeback_time_ms: parseInt(String(transformed.writeback_time_ms || 0), 10),
|
|
2042
|
-
fsyncs: parseInt(String(transformed.fsyncs || 0), 10),
|
|
2043
|
-
fsync_time_ms: parseInt(String(transformed.fsync_time_ms || 0), 10),
|
|
2044
|
-
extends: parseInt(String(transformed.extends || 0), 10),
|
|
2045
|
-
extend_bytes_mb: parseInt(String(transformed.extend_bytes_mb || 0), 10),
|
|
2046
|
-
hits: parseInt(String(transformed.hits || 0), 10),
|
|
2047
|
-
evictions: parseInt(String(transformed.evictions || 0), 10),
|
|
2048
|
-
reuses: parseInt(String(transformed.reuses || 0), 10),
|
|
2049
|
-
};
|
|
2050
|
-
});
|
|
2051
|
-
} catch (err) {
|
|
2052
|
-
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
2053
|
-
console.log(`[I001] Error fetching I/O statistics: ${errorMsg}`);
|
|
2054
|
-
return [];
|
|
2055
|
-
}
|
|
2056
|
-
}
|
|
2057
|
-
|
|
2058
|
-
/**
|
|
2059
|
-
* Generate I001 report - I/O statistics (pg_stat_io)
|
|
2060
|
-
*
|
|
2061
|
-
* This report collects I/O statistics from pg_stat_io (PostgreSQL 16+),
|
|
2062
|
-
* providing insights into read/write operations by backend type.
|
|
2063
|
-
*
|
|
2064
|
-
* @param client - Connected PostgreSQL client
|
|
2065
|
-
* @param nodeName - Node name for the report payload
|
|
2066
|
-
* @returns I001 report payload
|
|
2067
|
-
*/
|
|
2068
|
-
async function generateI001(client: Client, nodeName: string): Promise<Report> {
|
|
2069
|
-
const report = createBaseReport("I001", "I/O statistics (pg_stat_io)", nodeName);
|
|
2070
|
-
const postgresVersion = await getPostgresVersion(client);
|
|
2071
|
-
const parsedPgMajorVersion = parseInt(postgresVersion.server_major_ver, 10);
|
|
2072
|
-
const pgMajorVersion = Number.isFinite(parsedPgMajorVersion) ? parsedPgMajorVersion : 0;
|
|
2073
|
-
|
|
2074
|
-
// pg_stat_io requires PostgreSQL 16+
|
|
2075
|
-
if (pgMajorVersion < 16) {
|
|
2076
|
-
report.results[nodeName] = {
|
|
2077
|
-
data: {
|
|
2078
|
-
available: false,
|
|
2079
|
-
min_version_required: "16",
|
|
2080
|
-
by_backend_type: [],
|
|
2081
|
-
analysis: {
|
|
2082
|
-
total_read_mb: 0,
|
|
2083
|
-
total_write_mb: 0,
|
|
2084
|
-
total_io_time_ms: 0,
|
|
2085
|
-
read_hit_ratio_pct: 0,
|
|
2086
|
-
avg_read_time_ms: null,
|
|
2087
|
-
avg_write_time_ms: null,
|
|
2088
|
-
},
|
|
2089
|
-
stats_reset_s: null,
|
|
2090
|
-
},
|
|
2091
|
-
postgres_version: postgresVersion,
|
|
2092
|
-
};
|
|
2093
|
-
return report;
|
|
2094
|
-
}
|
|
2095
|
-
|
|
2096
|
-
const ioStats = await getIOStatistics(client, pgMajorVersion);
|
|
2097
|
-
|
|
2098
|
-
// Sort by backend_type, putting 'total' first if present
|
|
2099
|
-
ioStats.sort((a, b) => {
|
|
2100
|
-
if (a.backend_type === "total") return -1;
|
|
2101
|
-
if (b.backend_type === "total") return 1;
|
|
2102
|
-
return a.backend_type.localeCompare(b.backend_type);
|
|
2103
|
-
});
|
|
2104
|
-
|
|
2105
|
-
// Find 'total' row for analysis, or sum all rows if not present
|
|
2106
|
-
let totalStats = ioStats.find((s) => s.backend_type === "total");
|
|
2107
|
-
if (!totalStats && ioStats.length > 0) {
|
|
2108
|
-
totalStats = {
|
|
2109
|
-
backend_type: "total",
|
|
2110
|
-
reads: ioStats.reduce((sum, s) => sum + s.reads, 0),
|
|
2111
|
-
read_bytes_mb: ioStats.reduce((sum, s) => sum + s.read_bytes_mb, 0),
|
|
2112
|
-
read_time_ms: ioStats.reduce((sum, s) => sum + s.read_time_ms, 0),
|
|
2113
|
-
writes: ioStats.reduce((sum, s) => sum + s.writes, 0),
|
|
2114
|
-
write_bytes_mb: ioStats.reduce((sum, s) => sum + s.write_bytes_mb, 0),
|
|
2115
|
-
write_time_ms: ioStats.reduce((sum, s) => sum + s.write_time_ms, 0),
|
|
2116
|
-
writebacks: ioStats.reduce((sum, s) => sum + s.writebacks, 0),
|
|
2117
|
-
writeback_bytes_mb: ioStats.reduce((sum, s) => sum + s.writeback_bytes_mb, 0),
|
|
2118
|
-
writeback_time_ms: ioStats.reduce((sum, s) => sum + s.writeback_time_ms, 0),
|
|
2119
|
-
fsyncs: ioStats.reduce((sum, s) => sum + s.fsyncs, 0),
|
|
2120
|
-
fsync_time_ms: ioStats.reduce((sum, s) => sum + s.fsync_time_ms, 0),
|
|
2121
|
-
extends: ioStats.reduce((sum, s) => sum + (s.extends || 0), 0),
|
|
2122
|
-
extend_bytes_mb: ioStats.reduce((sum, s) => sum + (s.extend_bytes_mb || 0), 0),
|
|
2123
|
-
hits: ioStats.reduce((sum, s) => sum + s.hits, 0),
|
|
2124
|
-
evictions: ioStats.reduce((sum, s) => sum + s.evictions, 0),
|
|
2125
|
-
reuses: ioStats.reduce((sum, s) => sum + s.reuses, 0),
|
|
2126
|
-
};
|
|
2127
|
-
}
|
|
2128
|
-
|
|
2129
|
-
// Calculate analysis
|
|
2130
|
-
const totalReadMb = totalStats?.read_bytes_mb || 0;
|
|
2131
|
-
const totalWriteMb = totalStats?.write_bytes_mb || 0;
|
|
2132
|
-
const totalReadTime = totalStats?.read_time_ms || 0;
|
|
2133
|
-
const totalWriteTime = totalStats?.write_time_ms || 0;
|
|
2134
|
-
const totalIoTimeMs = totalReadTime + totalWriteTime;
|
|
2135
|
-
const totalReads = totalStats?.reads || 0;
|
|
2136
|
-
const totalWrites = totalStats?.writes || 0;
|
|
2137
|
-
const totalHits = totalStats?.hits || 0;
|
|
2138
|
-
|
|
2139
|
-
// Hit ratio: hits / (hits + reads) * 100
|
|
2140
|
-
const totalRequests = totalHits + totalReads;
|
|
2141
|
-
const readHitRatioPct = totalRequests > 0 ? Math.round((totalHits / totalRequests) * 10000) / 100 : 0;
|
|
2142
|
-
|
|
2143
|
-
// Average times
|
|
2144
|
-
const avgReadTimeMs = totalReads > 0 ? Math.round((totalReadTime / totalReads) * 1000) / 1000 : null;
|
|
2145
|
-
const avgWriteTimeMs = totalWrites > 0 ? Math.round((totalWriteTime / totalWrites) * 1000) / 1000 : null;
|
|
2146
|
-
|
|
2147
|
-
// Direct-connect checkup queries stats_reset separately instead of reading it from pgwatch metrics.
|
|
2148
|
-
let statsResetS: number | null = null;
|
|
2149
|
-
try {
|
|
2150
|
-
const resetResult = await client.query(`
|
|
2151
|
-
select max(extract(epoch from now() - stats_reset)::int) as stats_reset_s
|
|
2152
|
-
from pg_stat_io
|
|
2153
|
-
`);
|
|
2154
|
-
if (resetResult.rows.length > 0 && resetResult.rows[0].stats_reset_s !== null) {
|
|
2155
|
-
const parsedStatsResetS = parseInt(resetResult.rows[0].stats_reset_s, 10);
|
|
2156
|
-
statsResetS = Number.isFinite(parsedStatsResetS) ? parsedStatsResetS : null;
|
|
2157
|
-
}
|
|
2158
|
-
} catch (err) {
|
|
2159
|
-
// Ignore errors getting stats_reset - not critical
|
|
2160
|
-
}
|
|
2161
|
-
|
|
2162
|
-
report.results[nodeName] = {
|
|
2163
|
-
data: {
|
|
2164
|
-
available: ioStats.length > 0,
|
|
2165
|
-
by_backend_type: ioStats,
|
|
2166
|
-
analysis: {
|
|
2167
|
-
total_read_mb: totalReadMb,
|
|
2168
|
-
total_write_mb: totalWriteMb,
|
|
2169
|
-
total_io_time_ms: totalIoTimeMs,
|
|
2170
|
-
read_hit_ratio_pct: readHitRatioPct,
|
|
2171
|
-
avg_read_time_ms: avgReadTimeMs,
|
|
2172
|
-
avg_write_time_ms: avgWriteTimeMs,
|
|
2173
|
-
},
|
|
2174
|
-
stats_reset_s: statsResetS,
|
|
2175
|
-
},
|
|
2176
|
-
postgres_version: postgresVersion,
|
|
2177
|
-
};
|
|
2178
|
-
|
|
2179
|
-
return report;
|
|
2180
|
-
}
|
|
2181
|
-
|
|
2182
|
-
/**
|
|
2183
|
-
* Available report generators
|
|
2184
|
-
*/
|
|
2185
|
-
export const REPORT_GENERATORS: Record<string, (client: Client, nodeName: string) => Promise<Report>> = {
|
|
2186
|
-
A002: generateA002,
|
|
2187
|
-
A003: generateA003,
|
|
2188
|
-
A004: generateA004,
|
|
2189
|
-
A007: generateA007,
|
|
2190
|
-
A013: generateA013,
|
|
2191
|
-
D001: generateD001,
|
|
2192
|
-
D004: generateD004,
|
|
2193
|
-
F001: generateF001,
|
|
2194
|
-
F003: generateF003,
|
|
2195
|
-
F004: generateF004,
|
|
2196
|
-
F005: generateF005,
|
|
2197
|
-
G001: generateG001,
|
|
2198
|
-
G003: generateG003,
|
|
2199
|
-
H001: generateH001,
|
|
2200
|
-
H002: generateH002,
|
|
2201
|
-
H004: generateH004,
|
|
2202
|
-
I001: generateI001,
|
|
2203
|
-
};
|
|
2204
|
-
|
|
2205
|
-
/**
|
|
2206
|
-
* Check IDs and titles.
|
|
2207
|
-
*
|
|
2208
|
-
* This mapping is built from the embedded checkup dictionary, which is
|
|
2209
|
-
* fetched from https://postgres.ai/api/general/checkup_dictionary at build time.
|
|
2210
|
-
*
|
|
2211
|
-
* For the full dictionary (all available checks), use the checkup-dictionary module.
|
|
2212
|
-
* CHECK_INFO is filtered to only include checks that have express-mode generators.
|
|
2213
|
-
*/
|
|
2214
|
-
export const CHECK_INFO: Record<string, string> = (() => {
|
|
2215
|
-
// Build the full dictionary map
|
|
2216
|
-
const fullMap = buildCheckInfoMap();
|
|
2217
|
-
|
|
2218
|
-
// Filter to only include checks that have express-mode generators
|
|
2219
|
-
const expressCheckIds = Object.keys(REPORT_GENERATORS);
|
|
2220
|
-
const filtered: Record<string, string> = {};
|
|
2221
|
-
for (const checkId of expressCheckIds) {
|
|
2222
|
-
// Use dictionary title if available, otherwise use a fallback
|
|
2223
|
-
filtered[checkId] = fullMap[checkId] || checkId;
|
|
2224
|
-
}
|
|
2225
|
-
return filtered;
|
|
2226
|
-
})();
|
|
2227
|
-
|
|
2228
|
-
/**
|
|
2229
|
-
* Generate all available health check reports.
|
|
2230
|
-
* This is the main entry point for express mode checkup generation.
|
|
2231
|
-
*
|
|
2232
|
-
* @param client - Connected PostgreSQL client
|
|
2233
|
-
* @param nodeName - Node identifier for the report (default: "node-01")
|
|
2234
|
-
* @param onProgress - Optional callback for progress updates during generation
|
|
2235
|
-
* @returns Object mapping check IDs (e.g., "H001", "A002") to their reports
|
|
2236
|
-
* @throws {Error} If any critical report generation fails
|
|
2237
|
-
*/
|
|
2238
|
-
export async function generateAllReports(
|
|
2239
|
-
client: Client,
|
|
2240
|
-
nodeName: string = "node-01",
|
|
2241
|
-
onProgress?: (info: { checkId: string; checkTitle: string; index: number; total: number }) => void
|
|
2242
|
-
): Promise<Record<string, Report>> {
|
|
2243
|
-
const reports: Record<string, Report> = {};
|
|
2244
|
-
|
|
2245
|
-
const entries = Object.entries(REPORT_GENERATORS);
|
|
2246
|
-
const total = entries.length;
|
|
2247
|
-
let index = 0;
|
|
2248
|
-
|
|
2249
|
-
for (const [checkId, generator] of entries) {
|
|
2250
|
-
index += 1;
|
|
2251
|
-
onProgress?.({
|
|
2252
|
-
checkId,
|
|
2253
|
-
checkTitle: CHECK_INFO[checkId] || checkId,
|
|
2254
|
-
index,
|
|
2255
|
-
total,
|
|
2256
|
-
});
|
|
2257
|
-
reports[checkId] = await generator(client, nodeName);
|
|
2258
|
-
}
|
|
2259
|
-
|
|
2260
|
-
return reports;
|
|
2261
|
-
}
|