pgexplain 0.1.0 → 0.3.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/CHANGELOG.md +27 -0
- package/README.md +135 -3
- package/dist/cli.js +1384 -79
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +38 -2
- package/dist/index.js +629 -32
- package/dist/index.js.map +1 -1
- package/dist/server.js +4166 -0
- package/dist/server.js.map +1 -0
- package/dist/web/assets/PlanGraph-C5ap-Sga.css +1 -0
- package/dist/web/assets/PlanGraph-CD8gYPCY.js +23 -0
- package/dist/web/assets/index-D3fMyvfo.js +237 -0
- package/dist/web/assets/index-p4QC4qQe.css +1 -0
- package/dist/web/index.html +13 -0
- package/package.json +13 -2
package/dist/server.js
ADDED
|
@@ -0,0 +1,4166 @@
|
|
|
1
|
+
import { fileURLToPath } from 'url';
|
|
2
|
+
import { serve } from '@hono/node-server';
|
|
3
|
+
import { readFile, stat, mkdir, writeFile } from 'fs/promises';
|
|
4
|
+
import { resolve, join, normalize, extname } from 'path';
|
|
5
|
+
import { Hono } from 'hono';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import pc from 'picocolors';
|
|
8
|
+
import { mkdirSync } from 'fs';
|
|
9
|
+
import { homedir } from 'os';
|
|
10
|
+
import Database from 'better-sqlite3';
|
|
11
|
+
|
|
12
|
+
// src/server/start.ts
|
|
13
|
+
|
|
14
|
+
// src/diagnostics/diagnostic.ts
|
|
15
|
+
var AppError = class extends Error {
|
|
16
|
+
diagnostic;
|
|
17
|
+
exitCode;
|
|
18
|
+
constructor(diagnostic, exitCode, cause) {
|
|
19
|
+
super(diagnostic.title);
|
|
20
|
+
this.name = "AppError";
|
|
21
|
+
this.diagnostic = diagnostic;
|
|
22
|
+
this.exitCode = exitCode;
|
|
23
|
+
if (cause !== void 0) this.cause = cause;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
function finding(code, severity, parts) {
|
|
27
|
+
return { code, domain: "plan", severity, ...parts };
|
|
28
|
+
}
|
|
29
|
+
var SEVERITY_RANK = { error: 0, warn: 1, info: 2 };
|
|
30
|
+
function bySeverity(a, b) {
|
|
31
|
+
return SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
|
|
32
|
+
}
|
|
33
|
+
function maxSeverity(a, b) {
|
|
34
|
+
return SEVERITY_RANK[a] <= SEVERITY_RANK[b] ? a : b;
|
|
35
|
+
}
|
|
36
|
+
function scrubCredentials(input) {
|
|
37
|
+
if (!input) return input;
|
|
38
|
+
return input.replace(/(\b[a-z][a-z0-9+.-]*:\/\/[^:/?#@\s]+:)([^@\s]+)(@)/gi, "$1***$3").replace(/\bpassword\s*=\s*'[^']*'/gi, "password='***'").replace(/(\bpassword\s*=\s*)([^\s&'"]+)/gi, "$1***").replace(/(\bPGPASSWORD\s*=\s*)([^\s&'"]+)/gi, "$1***");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// package.json
|
|
42
|
+
var package_default = {
|
|
43
|
+
version: "0.3.0"};
|
|
44
|
+
|
|
45
|
+
// src/diagnostics/catalog.ts
|
|
46
|
+
var DOCS = "https://www.postgresql.org/docs/current";
|
|
47
|
+
var CATALOG = {
|
|
48
|
+
PGX_AUTH_FAILED: {
|
|
49
|
+
severity: "error",
|
|
50
|
+
exit: 5 /* Database */,
|
|
51
|
+
title: "Authentication failed",
|
|
52
|
+
detail: "The server rejected the supplied credentials.",
|
|
53
|
+
cause: "The password or role is wrong, or pg_hba.conf does not permit this role from your host.",
|
|
54
|
+
remediation: {
|
|
55
|
+
summary: "Verify the credentials and supply the password safely (never on the command line).",
|
|
56
|
+
steps: [
|
|
57
|
+
"Confirm the username and password are correct.",
|
|
58
|
+
"Provide the password via PGPASSWORD or ~/.pgpass instead of the command line.",
|
|
59
|
+
"Check that pg_hba.conf allows this role from your client host."
|
|
60
|
+
],
|
|
61
|
+
commands: [
|
|
62
|
+
{ label: "Set password via env", shell: "export PGPASSWORD=<password>" },
|
|
63
|
+
{
|
|
64
|
+
label: "Or store it (chmod 600)",
|
|
65
|
+
shell: 'echo "<host>:<port>:<db>:<user>:<password>" >> ~/.pgpass && chmod 600 ~/.pgpass'
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
},
|
|
69
|
+
docsUrl: `${DOCS}/auth-pg-hba-conf.html`
|
|
70
|
+
},
|
|
71
|
+
PGX_HOST_UNREACHABLE: {
|
|
72
|
+
severity: "error",
|
|
73
|
+
exit: 5 /* Database */,
|
|
74
|
+
title: "Cannot reach the PostgreSQL server",
|
|
75
|
+
detail: "DNS resolution failed or the TCP connection was refused.",
|
|
76
|
+
cause: "Wrong host/port, the server is down, or a firewall/VPN/security group is blocking the port.",
|
|
77
|
+
remediation: {
|
|
78
|
+
summary: "Verify the host and port, then probe reachability.",
|
|
79
|
+
steps: [
|
|
80
|
+
"Double-check --host and --port (or the DSN) for typos.",
|
|
81
|
+
"Confirm the server is running and accepts TCP connections.",
|
|
82
|
+
"Check VPN, firewall, and cloud security-group rules for the port."
|
|
83
|
+
],
|
|
84
|
+
commands: [{ label: "Probe reachability", shell: "pg_isready -h <host> -p <port>" }]
|
|
85
|
+
},
|
|
86
|
+
docsUrl: `${DOCS}/libpq-connect.html`
|
|
87
|
+
},
|
|
88
|
+
PGX_DB_NOT_FOUND: {
|
|
89
|
+
severity: "error",
|
|
90
|
+
exit: 5 /* Database */,
|
|
91
|
+
title: "Database does not exist",
|
|
92
|
+
detail: "The named database was not found on the server.",
|
|
93
|
+
cause: "The database name is misspelled or the database has not been created.",
|
|
94
|
+
remediation: {
|
|
95
|
+
summary: "List the available databases and correct the name.",
|
|
96
|
+
commands: [
|
|
97
|
+
{ label: "List databases", shell: "psql -h <host> -U <user> -l" },
|
|
98
|
+
{ label: "Re-run with the right name", shell: "pg-explain run --dbname <name> ..." }
|
|
99
|
+
]
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
PGX_SSL_REQUIRED: {
|
|
103
|
+
severity: "error",
|
|
104
|
+
exit: 5 /* Database */,
|
|
105
|
+
title: "Server requires SSL",
|
|
106
|
+
detail: "The server requires an encrypted connection but a plaintext one was offered.",
|
|
107
|
+
cause: "pg_hba.conf mandates SSL (e.g. `hostssl`) for this role/host.",
|
|
108
|
+
remediation: {
|
|
109
|
+
summary: "Enable SSL on the connection.",
|
|
110
|
+
commands: [
|
|
111
|
+
{ label: "Require encryption", shell: "pg-explain run --sslmode require ..." },
|
|
112
|
+
{
|
|
113
|
+
label: "Or verify the certificate too",
|
|
114
|
+
shell: "pg-explain run --sslmode verify-full --sslrootcert <ca.pem> ..."
|
|
115
|
+
}
|
|
116
|
+
]
|
|
117
|
+
},
|
|
118
|
+
docsUrl: `${DOCS}/libpq-ssl.html`
|
|
119
|
+
},
|
|
120
|
+
PGX_SSL_VERIFY_FAILED: {
|
|
121
|
+
severity: "error",
|
|
122
|
+
exit: 5 /* Database */,
|
|
123
|
+
title: "TLS certificate verification failed",
|
|
124
|
+
detail: "Under verify-full the certificate chain is untrusted or the hostname does not match.",
|
|
125
|
+
cause: "The CA is not trusted locally, or the certificate's CN/SAN does not match the host you connect to.",
|
|
126
|
+
remediation: {
|
|
127
|
+
summary: "Point at the CA bundle and confirm the hostname matches the certificate.",
|
|
128
|
+
steps: [
|
|
129
|
+
"Provide the CA certificate the server's cert chains to.",
|
|
130
|
+
"Confirm the certificate CN/SAN matches the --host value.",
|
|
131
|
+
"Only fall back to `--sslmode require` (encryption without identity check) if you accept the risk."
|
|
132
|
+
],
|
|
133
|
+
commands: [
|
|
134
|
+
{
|
|
135
|
+
label: "Trust a CA",
|
|
136
|
+
shell: "pg-explain run --sslmode verify-full --sslrootcert <ca.pem> ..."
|
|
137
|
+
}
|
|
138
|
+
]
|
|
139
|
+
},
|
|
140
|
+
docsUrl: `${DOCS}/libpq-ssl.html`
|
|
141
|
+
},
|
|
142
|
+
PGX_CONN_TIMEOUT: {
|
|
143
|
+
severity: "error",
|
|
144
|
+
exit: 5 /* Database */,
|
|
145
|
+
title: "Connection timed out",
|
|
146
|
+
detail: "The connect handshake did not complete within the connect deadline.",
|
|
147
|
+
cause: "High network latency, an overloaded server, or a firewall silently dropping packets.",
|
|
148
|
+
remediation: {
|
|
149
|
+
summary: "Raise the connect budget and investigate the network path.",
|
|
150
|
+
commands: [
|
|
151
|
+
{ label: "Increase connect timeout", shell: "pg-explain run --connect-timeout 30s ..." }
|
|
152
|
+
]
|
|
153
|
+
},
|
|
154
|
+
docsUrl: `${DOCS}/libpq-connect.html`
|
|
155
|
+
},
|
|
156
|
+
PGX_PERMISSION_DENIED: {
|
|
157
|
+
severity: "error",
|
|
158
|
+
exit: 5 /* Database */,
|
|
159
|
+
title: "Permission denied",
|
|
160
|
+
detail: "The connecting role lacks a privilege the query needs.",
|
|
161
|
+
cause: "EXPLAIN must plan the query, which requires SELECT (and any referenced privileges) on the relations.",
|
|
162
|
+
remediation: {
|
|
163
|
+
summary: "Grant the missing privilege, or connect with a role that already has it.",
|
|
164
|
+
commands: [
|
|
165
|
+
{ label: "Grant SELECT (run as owner)", sql: "GRANT SELECT ON <table> TO <role>;" }
|
|
166
|
+
]
|
|
167
|
+
},
|
|
168
|
+
docsUrl: `${DOCS}/sql-grant.html`
|
|
169
|
+
},
|
|
170
|
+
PGX_RELATION_NOT_FOUND: {
|
|
171
|
+
severity: "error",
|
|
172
|
+
exit: 5 /* Database */,
|
|
173
|
+
title: "Relation does not exist",
|
|
174
|
+
detail: "A table or view referenced by the query was not found.",
|
|
175
|
+
cause: "The name is misspelled, or it lives in a schema that is not on the search_path.",
|
|
176
|
+
remediation: {
|
|
177
|
+
summary: "Schema-qualify the relation or set the search_path.",
|
|
178
|
+
steps: ["Check spelling and the schema.", "List tables with `\\dt` in psql."],
|
|
179
|
+
commands: [{ label: "Set the search path", sql: "SET search_path = <schema>, public;" }]
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
PGX_STATEMENT_TIMEOUT: {
|
|
183
|
+
severity: "error",
|
|
184
|
+
exit: 5 /* Database */,
|
|
185
|
+
title: "Statement timeout reached",
|
|
186
|
+
detail: "statement_timeout fired while EXPLAIN ANALYZE was executing the query.",
|
|
187
|
+
cause: "The query genuinely takes longer than the configured statement_timeout to run.",
|
|
188
|
+
remediation: {
|
|
189
|
+
summary: "Raise the timeout, or avoid executing the query at all.",
|
|
190
|
+
steps: [
|
|
191
|
+
"Raise the per-run statement timeout.",
|
|
192
|
+
"Or get an estimate-only plan that never executes (drop --analyze).",
|
|
193
|
+
"Or reduce measurement overhead with --timing off."
|
|
194
|
+
],
|
|
195
|
+
commands: [
|
|
196
|
+
{ label: "Raise the timeout", shell: "pg-explain run --statement-timeout 60s ..." }
|
|
197
|
+
]
|
|
198
|
+
},
|
|
199
|
+
docsUrl: `${DOCS}/runtime-config-client.html#GUC-STATEMENT-TIMEOUT`
|
|
200
|
+
},
|
|
201
|
+
PGX_LOCK_TIMEOUT: {
|
|
202
|
+
severity: "error",
|
|
203
|
+
exit: 5 /* Database */,
|
|
204
|
+
title: "Lock timeout reached",
|
|
205
|
+
detail: "lock_timeout elapsed while waiting to acquire a lock on a relation.",
|
|
206
|
+
cause: "Another transaction holds a conflicting lock on a relation the query touches.",
|
|
207
|
+
remediation: {
|
|
208
|
+
summary: "Raise the lock timeout, identify the blocker, or retry off-peak.",
|
|
209
|
+
commands: [
|
|
210
|
+
{ label: "Raise the lock timeout", shell: "pg-explain run --lock-timeout 30s ..." },
|
|
211
|
+
{
|
|
212
|
+
label: "Find blockers",
|
|
213
|
+
sql: "SELECT * FROM pg_locks l JOIN pg_stat_activity a USING (pid) WHERE NOT l.granted;"
|
|
214
|
+
}
|
|
215
|
+
]
|
|
216
|
+
},
|
|
217
|
+
docsUrl: `${DOCS}/runtime-config-client.html#GUC-LOCK-TIMEOUT`
|
|
218
|
+
},
|
|
219
|
+
PGX_QUERY_CANCELED: {
|
|
220
|
+
severity: "error",
|
|
221
|
+
exit: 5 /* Database */,
|
|
222
|
+
title: "Query was canceled",
|
|
223
|
+
detail: "The query was canceled by an administrator or a signal before completing.",
|
|
224
|
+
cause: "An admin pg_cancel_backend call, a resource group, or a pool limit canceled the statement.",
|
|
225
|
+
remediation: {
|
|
226
|
+
summary: "Re-run the command; if it recurs, check for admin cancellation or pool limits."
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
PGX_UNSUPPORTED_PG_VERSION: {
|
|
230
|
+
severity: "error",
|
|
231
|
+
exit: 2 /* Usage */,
|
|
232
|
+
title: "EXPLAIN option not supported by this server",
|
|
233
|
+
detail: "A requested EXPLAIN option requires a newer PostgreSQL major version.",
|
|
234
|
+
cause: "Options are version-gated (e.g. SETTINGS\u226512, WAL\u226513, GENERIC_PLAN\u226516, SERIALIZE/MEMORY\u226517).",
|
|
235
|
+
remediation: {
|
|
236
|
+
summary: "Drop the unsupported option, target a newer server, or let pg-explain auto-omit it.",
|
|
237
|
+
commands: [{ label: "Auto-omit unsupported options", shell: "pg-explain run --compat ..." }]
|
|
238
|
+
},
|
|
239
|
+
docsUrl: `${DOCS}/sql-explain.html`
|
|
240
|
+
},
|
|
241
|
+
PGX_INVALID_EXPLAIN_OPTION: {
|
|
242
|
+
severity: "error",
|
|
243
|
+
exit: 2 /* Usage */,
|
|
244
|
+
title: "Invalid EXPLAIN option combination",
|
|
245
|
+
detail: "The server rejected an EXPLAIN option or a mutually-exclusive combination.",
|
|
246
|
+
cause: "Some options require ANALYZE (WAL, SERIALIZE, TIMING) and GENERIC_PLAN is incompatible with ANALYZE.",
|
|
247
|
+
remediation: {
|
|
248
|
+
summary: "Fix the option combination; see `pg-explain --help` for valid combinations.",
|
|
249
|
+
steps: [
|
|
250
|
+
"WAL/SERIALIZE/TIMING require --analyze.",
|
|
251
|
+
"GENERIC_PLAN cannot be combined with --analyze."
|
|
252
|
+
]
|
|
253
|
+
},
|
|
254
|
+
docsUrl: `${DOCS}/sql-explain.html`
|
|
255
|
+
},
|
|
256
|
+
PGX_MALFORMED_JSON: {
|
|
257
|
+
severity: "error",
|
|
258
|
+
exit: 4 /* Parse */,
|
|
259
|
+
title: "Input is not valid JSON",
|
|
260
|
+
detail: "The plan input could not be parsed as JSON.",
|
|
261
|
+
cause: "The input was truncated when captured, or it is not EXPLAIN (FORMAT JSON) output.",
|
|
262
|
+
remediation: {
|
|
263
|
+
summary: "Validate the input and make sure it is FORMAT JSON output.",
|
|
264
|
+
commands: [{ label: "Validate JSON", shell: "jq . plan.json" }]
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
PGX_UNEXPECTED_PLAN_SHAPE: {
|
|
268
|
+
severity: "error",
|
|
269
|
+
exit: 4 /* Parse */,
|
|
270
|
+
title: "Input is not an EXPLAIN plan",
|
|
271
|
+
detail: "The JSON parsed but does not contain a recognizable EXPLAIN plan tree.",
|
|
272
|
+
cause: "The 'Plan' node is missing \u2014 this may be query result rows rather than a plan.",
|
|
273
|
+
remediation: {
|
|
274
|
+
summary: "Regenerate the plan with FORMAT JSON and pipe that in.",
|
|
275
|
+
commands: [
|
|
276
|
+
{ label: "Capture a plan", sql: "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) <your query>;" }
|
|
277
|
+
]
|
|
278
|
+
},
|
|
279
|
+
docsUrl: `${DOCS}/sql-explain.html`
|
|
280
|
+
},
|
|
281
|
+
PGX_EMPTY_INPUT: {
|
|
282
|
+
severity: "error",
|
|
283
|
+
exit: 3 /* Input */,
|
|
284
|
+
title: "No plan input received",
|
|
285
|
+
detail: "stdin and the named file were both empty.",
|
|
286
|
+
cause: "No plan was piped in and no query/file was provided.",
|
|
287
|
+
remediation: {
|
|
288
|
+
summary: "Pipe a plan, or provide SQL to run.",
|
|
289
|
+
commands: [
|
|
290
|
+
{ label: "Analyze a saved plan", shell: "pg-explain < plan.json" },
|
|
291
|
+
{ label: "Or run a query", shell: 'pg-explain run --query "<sql>" --dsn <dsn>' }
|
|
292
|
+
]
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
PGX_NON_SELECT_REFUSED: {
|
|
296
|
+
severity: "error",
|
|
297
|
+
exit: 2 /* Usage */,
|
|
298
|
+
title: "Refusing to ANALYZE a data-modifying statement",
|
|
299
|
+
detail: "EXPLAIN ANALYZE executes the statement, and this one would modify data.",
|
|
300
|
+
cause: "INSERT/UPDATE/DELETE/MERGE/DDL run for real under ANALYZE; running it could change your data.",
|
|
301
|
+
remediation: {
|
|
302
|
+
summary: "Use --force to run it inside an automatically rolled-back transaction, or drop --analyze.",
|
|
303
|
+
steps: [
|
|
304
|
+
"With --force, pg-explain wraps it as `BEGIN; <stmt>; ROLLBACK;` so nothing is committed.",
|
|
305
|
+
"Without --analyze you get an estimate-only plan that never executes."
|
|
306
|
+
],
|
|
307
|
+
commands: [
|
|
308
|
+
{
|
|
309
|
+
label: "Run safely (auto-rollback)",
|
|
310
|
+
shell: "pg-explain run --force --file mutation.sql --dsn <dsn>"
|
|
311
|
+
}
|
|
312
|
+
]
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
PGX_MULTIPLE_STATEMENTS: {
|
|
316
|
+
severity: "error",
|
|
317
|
+
exit: 2 /* Usage */,
|
|
318
|
+
title: "Multiple SQL statements found",
|
|
319
|
+
detail: "The input contains more than one statement; pg-explain analyzes one at a time.",
|
|
320
|
+
cause: "A .sql file or --query string contained several semicolon-separated statements.",
|
|
321
|
+
remediation: {
|
|
322
|
+
summary: "Select one statement, or split them into separate invocations.",
|
|
323
|
+
commands: [
|
|
324
|
+
{
|
|
325
|
+
label: "Pick the Nth statement (1-based)",
|
|
326
|
+
shell: "pg-explain run --statement 2 --file queries.sql --dsn <dsn>"
|
|
327
|
+
}
|
|
328
|
+
]
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
PGX_COST_ONLY_PLAN: {
|
|
332
|
+
severity: "info",
|
|
333
|
+
exit: 0 /* Success */,
|
|
334
|
+
title: "Cost-only plan \u2014 estimate-vs-actual checks unavailable",
|
|
335
|
+
detail: "This plan has cost estimates but no actual row/time data.",
|
|
336
|
+
cause: "It was produced by plain EXPLAIN (without ANALYZE), so runtime behavior is unknown.",
|
|
337
|
+
remediation: {
|
|
338
|
+
summary: "Re-run with ANALYZE to unlock estimate-vs-actual, timing, and spill findings.",
|
|
339
|
+
commands: [
|
|
340
|
+
{ label: "Capture actuals", sql: "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) <query>;" }
|
|
341
|
+
]
|
|
342
|
+
},
|
|
343
|
+
docsUrl: `${DOCS}/using-explain.html#USING-EXPLAIN-ANALYZE`
|
|
344
|
+
},
|
|
345
|
+
PGX_NO_BUFFERS: {
|
|
346
|
+
severity: "info",
|
|
347
|
+
exit: 0 /* Success */,
|
|
348
|
+
title: "No BUFFERS data \u2014 cache/I/O analysis skipped",
|
|
349
|
+
detail: "Buffer counters are absent, so cache-hit ratio and I/O findings cannot be computed.",
|
|
350
|
+
cause: "The plan was captured without BUFFERS.",
|
|
351
|
+
remediation: {
|
|
352
|
+
summary: "Add BUFFERS to surface shared/temp block usage and the cache-hit ratio.",
|
|
353
|
+
commands: [
|
|
354
|
+
{ label: "Capture buffers", sql: "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) <query>;" }
|
|
355
|
+
]
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
PGX_EMPTY_PLAN: {
|
|
359
|
+
severity: "info",
|
|
360
|
+
exit: 0 /* Success */,
|
|
361
|
+
title: "Nothing to analyze",
|
|
362
|
+
detail: "The plan has no scans or joins to evaluate (e.g. a bare Result node).",
|
|
363
|
+
cause: "The query is trivial and has no tuning surface.",
|
|
364
|
+
remediation: { summary: "Confirm this is the query you intended to profile." }
|
|
365
|
+
},
|
|
366
|
+
PGX_PG_DRIVER_MISSING: {
|
|
367
|
+
severity: "error",
|
|
368
|
+
exit: 2 /* Usage */,
|
|
369
|
+
title: "The 'pg' driver is not installed",
|
|
370
|
+
detail: "The run command needs the PostgreSQL driver, which is an optional dependency.",
|
|
371
|
+
cause: "pgexplain ships 'pg' as optional so plan-only use stays dependency-free; it isn't installed here.",
|
|
372
|
+
remediation: {
|
|
373
|
+
summary: "Install the pg driver, then re-run. (Plan-only analysis from a file/stdin needs no driver.)",
|
|
374
|
+
commands: [
|
|
375
|
+
{ label: "with pnpm", shell: "pnpm add pg" },
|
|
376
|
+
{ label: "with npm", shell: "npm install pg" }
|
|
377
|
+
]
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
PGX_QUERY_FAILED: {
|
|
381
|
+
severity: "error",
|
|
382
|
+
exit: 5 /* Database */,
|
|
383
|
+
title: "The query could not be planned or executed",
|
|
384
|
+
detail: "PostgreSQL returned an error while running EXPLAIN.",
|
|
385
|
+
cause: "The statement has a syntax error, references something invalid, or hit a server-side limit.",
|
|
386
|
+
remediation: {
|
|
387
|
+
summary: "Read the server message below, fix the statement, and re-run. Test it in psql first if unsure.",
|
|
388
|
+
commands: [{ label: "Try it directly", shell: 'psql "<dsn>" -c "EXPLAIN <your statement>"' }]
|
|
389
|
+
},
|
|
390
|
+
docsUrl: `${DOCS}/sql-explain.html`
|
|
391
|
+
},
|
|
392
|
+
PGX_INTERNAL: {
|
|
393
|
+
severity: "error",
|
|
394
|
+
exit: 70 /* Internal */,
|
|
395
|
+
title: "pg-explain hit an unexpected error",
|
|
396
|
+
detail: "This is a bug in pg-explain, not in your query or plan.",
|
|
397
|
+
cause: "An unhandled condition was reached.",
|
|
398
|
+
remediation: {
|
|
399
|
+
summary: "Re-run with --debug for a credential-scrubbed stack trace, then file an issue.",
|
|
400
|
+
commands: [
|
|
401
|
+
{ label: "Show the trace", shell: "pg-explain --debug ..." },
|
|
402
|
+
{ label: "Report your version", shell: "pg-explain --version" }
|
|
403
|
+
]
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
function opDiagnostic(code, overrides = {}) {
|
|
408
|
+
const spec = CATALOG[code];
|
|
409
|
+
const diag = {
|
|
410
|
+
code,
|
|
411
|
+
domain: "operational",
|
|
412
|
+
severity: spec.severity,
|
|
413
|
+
title: spec.title,
|
|
414
|
+
detail: overrides.detail ?? spec.detail,
|
|
415
|
+
cause: spec.cause,
|
|
416
|
+
remediation: spec.remediation
|
|
417
|
+
};
|
|
418
|
+
if (spec.docsUrl) diag.docsUrl = spec.docsUrl;
|
|
419
|
+
if (overrides.location) diag.location = overrides.location;
|
|
420
|
+
if (overrides.meta) diag.meta = overrides.meta;
|
|
421
|
+
return diag;
|
|
422
|
+
}
|
|
423
|
+
function opError(code, overrides = {}, cause) {
|
|
424
|
+
return new AppError(opDiagnostic(code, overrides), CATALOG[code].exit, cause);
|
|
425
|
+
}
|
|
426
|
+
function logVerbose(msg) {
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// src/db/version.ts
|
|
430
|
+
function capabilities(versionNum) {
|
|
431
|
+
const major = Math.floor(versionNum / 1e4);
|
|
432
|
+
return {
|
|
433
|
+
versionNum,
|
|
434
|
+
major,
|
|
435
|
+
summary: major >= 10,
|
|
436
|
+
settings: major >= 12,
|
|
437
|
+
wal: major >= 13,
|
|
438
|
+
genericPlan: major >= 16,
|
|
439
|
+
serialize: major >= 17,
|
|
440
|
+
memory: major >= 17
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
function versionLabel(versionNum) {
|
|
444
|
+
const major = Math.floor(versionNum / 1e4);
|
|
445
|
+
const minor = versionNum % 100;
|
|
446
|
+
return `${major}.${minor}`;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// src/db/explain.ts
|
|
450
|
+
var DEFAULT_EXPLAIN_FLAGS = {
|
|
451
|
+
analyze: true,
|
|
452
|
+
buffers: true,
|
|
453
|
+
verbose: false,
|
|
454
|
+
settings: false,
|
|
455
|
+
wal: false,
|
|
456
|
+
timing: true,
|
|
457
|
+
costs: true,
|
|
458
|
+
summary: true,
|
|
459
|
+
genericPlan: false,
|
|
460
|
+
compat: false
|
|
461
|
+
};
|
|
462
|
+
function buildExplain(flags, caps) {
|
|
463
|
+
if (flags.genericPlan && flags.analyze) {
|
|
464
|
+
throw opError("PGX_INVALID_EXPLAIN_OPTION", {
|
|
465
|
+
detail: "GENERIC_PLAN cannot be combined with ANALYZE (GENERIC_PLAN does not execute the query)."
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
if (flags.wal && !flags.analyze) {
|
|
469
|
+
throw opError("PGX_INVALID_EXPLAIN_OPTION", { detail: "WAL requires ANALYZE." });
|
|
470
|
+
}
|
|
471
|
+
const opts = ["FORMAT JSON"];
|
|
472
|
+
const omitted = [];
|
|
473
|
+
const gate = (label, supported, requiredMajor) => {
|
|
474
|
+
if (supported) return true;
|
|
475
|
+
if (flags.compat) {
|
|
476
|
+
omitted.push(label);
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
throw opError("PGX_UNSUPPORTED_PG_VERSION", {
|
|
480
|
+
detail: `EXPLAIN (${label}) requires PostgreSQL ${requiredMajor}; server is ${versionLabel(caps.versionNum)}.`,
|
|
481
|
+
meta: { option: label, requiredMajor, serverVersion: caps.versionNum }
|
|
482
|
+
});
|
|
483
|
+
};
|
|
484
|
+
if (flags.genericPlan && gate("GENERIC_PLAN", caps.genericPlan, 16)) opts.push("GENERIC_PLAN");
|
|
485
|
+
if (flags.analyze) opts.push("ANALYZE");
|
|
486
|
+
if (flags.buffers) opts.push("BUFFERS");
|
|
487
|
+
if (flags.verbose) opts.push("VERBOSE");
|
|
488
|
+
if (flags.settings && gate("SETTINGS", caps.settings, 12)) opts.push("SETTINGS");
|
|
489
|
+
if (flags.wal && gate("WAL", caps.wal, 13)) opts.push("WAL");
|
|
490
|
+
if (!flags.costs) opts.push("COSTS OFF");
|
|
491
|
+
if (flags.analyze && !flags.timing) opts.push("TIMING OFF");
|
|
492
|
+
if (!flags.summary && caps.summary) opts.push("SUMMARY OFF");
|
|
493
|
+
return { prefix: `EXPLAIN (${opts.join(", ")})`, omitted };
|
|
494
|
+
}
|
|
495
|
+
function leadingKeyword(sql) {
|
|
496
|
+
const cleaned = sql.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/--[^\n]*/g, " ").trim();
|
|
497
|
+
return (cleaned.split(/\s+/)[0] ?? "").toUpperCase();
|
|
498
|
+
}
|
|
499
|
+
function isReadOnlyStatement(sql) {
|
|
500
|
+
const kw = leadingKeyword(sql);
|
|
501
|
+
if (["SELECT", "TABLE", "VALUES", "SHOW", "EXPLAIN"].includes(kw)) return true;
|
|
502
|
+
if (kw === "WITH") return !/\b(INSERT|UPDATE|DELETE|MERGE)\b/i.test(sql);
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
function splitStatements(sql) {
|
|
506
|
+
const out = [];
|
|
507
|
+
let buf = "";
|
|
508
|
+
let i = 0;
|
|
509
|
+
const n = sql.length;
|
|
510
|
+
while (i < n) {
|
|
511
|
+
const ch = sql[i];
|
|
512
|
+
const two = sql.slice(i, i + 2);
|
|
513
|
+
if (two === "--") {
|
|
514
|
+
const nl = sql.indexOf("\n", i);
|
|
515
|
+
const end = nl === -1 ? n : nl;
|
|
516
|
+
buf += sql.slice(i, end);
|
|
517
|
+
i = end;
|
|
518
|
+
} else if (two === "/*") {
|
|
519
|
+
const close = sql.indexOf("*/", i + 2);
|
|
520
|
+
const end = close === -1 ? n : close + 2;
|
|
521
|
+
buf += sql.slice(i, end);
|
|
522
|
+
i = end;
|
|
523
|
+
} else if (ch === "'" || ch === '"') {
|
|
524
|
+
const end = scanQuoted(sql, i, ch);
|
|
525
|
+
buf += sql.slice(i, end);
|
|
526
|
+
i = end;
|
|
527
|
+
} else if (ch === "$") {
|
|
528
|
+
const tag = matchDollarTag(sql, i);
|
|
529
|
+
if (tag) {
|
|
530
|
+
const close = sql.indexOf(tag, i + tag.length);
|
|
531
|
+
const end = close === -1 ? n : close + tag.length;
|
|
532
|
+
buf += sql.slice(i, end);
|
|
533
|
+
i = end;
|
|
534
|
+
} else {
|
|
535
|
+
buf += ch;
|
|
536
|
+
i++;
|
|
537
|
+
}
|
|
538
|
+
} else if (ch === ";") {
|
|
539
|
+
if (buf.trim()) out.push(buf.trim());
|
|
540
|
+
buf = "";
|
|
541
|
+
i++;
|
|
542
|
+
} else {
|
|
543
|
+
buf += ch;
|
|
544
|
+
i++;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (buf.trim()) out.push(buf.trim());
|
|
548
|
+
return out;
|
|
549
|
+
}
|
|
550
|
+
function scanQuoted(sql, start, quote) {
|
|
551
|
+
let i = start + 1;
|
|
552
|
+
while (i < sql.length) {
|
|
553
|
+
if (sql[i] === quote) {
|
|
554
|
+
if (sql[i + 1] === quote)
|
|
555
|
+
i += 2;
|
|
556
|
+
else return i + 1;
|
|
557
|
+
} else {
|
|
558
|
+
i++;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return sql.length;
|
|
562
|
+
}
|
|
563
|
+
function matchDollarTag(sql, start) {
|
|
564
|
+
const m = /^\$[A-Za-z_]*\$/.exec(sql.slice(start));
|
|
565
|
+
return m ? m[0] : null;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// src/db/client.ts
|
|
569
|
+
async function newClient(config) {
|
|
570
|
+
let mod;
|
|
571
|
+
try {
|
|
572
|
+
mod = await import('pg');
|
|
573
|
+
} catch (err) {
|
|
574
|
+
throw opError("PGX_PG_DRIVER_MISSING", {}, err);
|
|
575
|
+
}
|
|
576
|
+
const lib = mod.default ?? mod;
|
|
577
|
+
return new lib.Client(config);
|
|
578
|
+
}
|
|
579
|
+
function buildClientConfig(c, ca) {
|
|
580
|
+
const config = { connectionTimeoutMillis: c.connectTimeoutMs };
|
|
581
|
+
if (c.dsn) {
|
|
582
|
+
config.connectionString = c.dsn;
|
|
583
|
+
} else {
|
|
584
|
+
if (c.host) config.host = c.host;
|
|
585
|
+
if (c.port) config.port = c.port;
|
|
586
|
+
if (c.database) config.database = c.database;
|
|
587
|
+
if (c.user) config.user = c.user;
|
|
588
|
+
if (c.password) config.password = c.password;
|
|
589
|
+
}
|
|
590
|
+
if (c.sslmode && c.sslmode !== "disable" && c.sslmode !== "prefer") {
|
|
591
|
+
const verify = c.sslmode === "verify-ca" || c.sslmode === "verify-full";
|
|
592
|
+
config.ssl = ca ? { rejectUnauthorized: verify, ca } : { rejectUnauthorized: verify };
|
|
593
|
+
} else if (c.sslmode === "disable") {
|
|
594
|
+
config.ssl = false;
|
|
595
|
+
}
|
|
596
|
+
return config;
|
|
597
|
+
}
|
|
598
|
+
async function runExplain(opts) {
|
|
599
|
+
const ca = opts.connection.sslrootcert ? await readFile(opts.connection.sslrootcert, "utf8").catch((err) => {
|
|
600
|
+
throw opError("PGX_SSL_VERIFY_FAILED", {
|
|
601
|
+
detail: `Could not read --sslrootcert '${opts.connection.sslrootcert}': ${err instanceof Error ? err.message : String(err)}`
|
|
602
|
+
});
|
|
603
|
+
}) : void 0;
|
|
604
|
+
const client = await newClient(buildClientConfig(opts.connection, ca));
|
|
605
|
+
try {
|
|
606
|
+
await client.connect();
|
|
607
|
+
} catch (err) {
|
|
608
|
+
throw mapConnectError(err);
|
|
609
|
+
}
|
|
610
|
+
try {
|
|
611
|
+
const verNum = await fetchVersionNum(client);
|
|
612
|
+
const caps = capabilities(verNum);
|
|
613
|
+
const built = buildExplain(opts.flags, caps);
|
|
614
|
+
const explainSql = `${built.prefix} ${opts.statement}`;
|
|
615
|
+
logVerbose(`server_version_num=${verNum}; ${built.prefix}`);
|
|
616
|
+
const useTxn = opts.rollback;
|
|
617
|
+
if (useTxn) await client.query("BEGIN");
|
|
618
|
+
try {
|
|
619
|
+
if (useTxn) {
|
|
620
|
+
await client.query(`SET LOCAL statement_timeout = ${msInt(opts.statementTimeoutMs)}`);
|
|
621
|
+
await client.query(`SET LOCAL lock_timeout = ${msInt(opts.lockTimeoutMs)}`);
|
|
622
|
+
if (!opts.forceWrite) await client.query("SET LOCAL transaction_read_only = on");
|
|
623
|
+
}
|
|
624
|
+
const res = await client.query({
|
|
625
|
+
text: explainSql,
|
|
626
|
+
values: opts.params ?? []
|
|
627
|
+
});
|
|
628
|
+
return { json: extractPlanJson(res.rows), caps, omitted: built.omitted };
|
|
629
|
+
} catch (err) {
|
|
630
|
+
throw mapQueryError(err);
|
|
631
|
+
} finally {
|
|
632
|
+
if (useTxn) await client.query("ROLLBACK").catch(() => {
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
} finally {
|
|
636
|
+
await client.end().catch(() => {
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
function msInt(ms) {
|
|
641
|
+
return Math.max(0, Math.floor(ms));
|
|
642
|
+
}
|
|
643
|
+
async function queryReadOnly(connection, sql, params = [], timeoutMs = 1e4) {
|
|
644
|
+
const ca = connection.sslrootcert ? await readFile(connection.sslrootcert, "utf8").catch(() => void 0) : void 0;
|
|
645
|
+
const client = await newClient(buildClientConfig(connection, ca));
|
|
646
|
+
try {
|
|
647
|
+
await client.connect();
|
|
648
|
+
} catch (err) {
|
|
649
|
+
throw mapConnectError(err);
|
|
650
|
+
}
|
|
651
|
+
try {
|
|
652
|
+
await client.query(`SET statement_timeout = ${msInt(timeoutMs)}`);
|
|
653
|
+
const res = await client.query({ text: sql, values: params });
|
|
654
|
+
return res.rows;
|
|
655
|
+
} catch (err) {
|
|
656
|
+
throw mapQueryError(err);
|
|
657
|
+
} finally {
|
|
658
|
+
await client.end().catch(() => {
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
async function explainScript(connection, units, opts) {
|
|
663
|
+
const ca = connection.sslrootcert ? await readFile(connection.sslrootcert, "utf8").catch(() => void 0) : void 0;
|
|
664
|
+
const client = await newClient(buildClientConfig(connection, ca));
|
|
665
|
+
try {
|
|
666
|
+
await client.connect();
|
|
667
|
+
} catch (err) {
|
|
668
|
+
throw mapConnectError(err);
|
|
669
|
+
}
|
|
670
|
+
try {
|
|
671
|
+
const caps = capabilities(await fetchVersionNum(client));
|
|
672
|
+
await client.query("BEGIN");
|
|
673
|
+
const results = [];
|
|
674
|
+
try {
|
|
675
|
+
await client.query(`SET LOCAL statement_timeout = ${msInt(opts.statementTimeoutMs)}`);
|
|
676
|
+
await client.query(`SET LOCAL lock_timeout = ${msInt(opts.lockTimeoutMs)}`);
|
|
677
|
+
await client.query("SET LOCAL transaction_read_only = on");
|
|
678
|
+
for (const unit of units) {
|
|
679
|
+
const flags = {
|
|
680
|
+
analyze: false,
|
|
681
|
+
// never execute
|
|
682
|
+
buffers: false,
|
|
683
|
+
// BUFFERS requires ANALYZE pre-16
|
|
684
|
+
verbose: opts.verbose ?? false,
|
|
685
|
+
settings: opts.settings ?? false,
|
|
686
|
+
wal: false,
|
|
687
|
+
timing: false,
|
|
688
|
+
costs: true,
|
|
689
|
+
summary: false,
|
|
690
|
+
genericPlan: caps.genericPlan && /\$\d+/.test(unit.sql),
|
|
691
|
+
compat: true
|
|
692
|
+
// auto-omit anything the server is too old for
|
|
693
|
+
};
|
|
694
|
+
try {
|
|
695
|
+
const { prefix } = buildExplain(flags, caps);
|
|
696
|
+
const res = await client.query(`${prefix} ${unit.sql}`);
|
|
697
|
+
const r = { label: unit.label, planJson: extractPlanJson(res.rows) };
|
|
698
|
+
if (unit.loopNote) r.loopNote = unit.loopNote;
|
|
699
|
+
results.push(r);
|
|
700
|
+
} catch (err) {
|
|
701
|
+
const diag = err instanceof AppError ? err.diagnostic : mapQueryError(err).diagnostic;
|
|
702
|
+
const r = { label: unit.label, error: diag };
|
|
703
|
+
if (unit.loopNote) r.loopNote = unit.loopNote;
|
|
704
|
+
results.push(r);
|
|
705
|
+
await client.query("ROLLBACK").catch(() => {
|
|
706
|
+
});
|
|
707
|
+
await client.query("BEGIN").catch(() => {
|
|
708
|
+
});
|
|
709
|
+
await client.query("SET LOCAL transaction_read_only = on").catch(() => {
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return { units: results, caps };
|
|
714
|
+
} finally {
|
|
715
|
+
await client.query("ROLLBACK").catch(() => {
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
} finally {
|
|
719
|
+
await client.end().catch(() => {
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
async function fetchVersionNum(client) {
|
|
724
|
+
try {
|
|
725
|
+
const res = await client.query("SHOW server_version_num");
|
|
726
|
+
return Number(res.rows[0]?.server_version_num ?? 0);
|
|
727
|
+
} catch (err) {
|
|
728
|
+
throw mapQueryError(err);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
function extractPlanJson(rows) {
|
|
732
|
+
const value = rows[0]?.["QUERY PLAN"];
|
|
733
|
+
if (value === void 0) {
|
|
734
|
+
throw opError("PGX_UNEXPECTED_PLAN_SHAPE", {
|
|
735
|
+
detail: "The server returned no plan rows for EXPLAIN."
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
739
|
+
}
|
|
740
|
+
function asPgError(err) {
|
|
741
|
+
if (err && typeof err === "object") return err;
|
|
742
|
+
return { message: String(err) };
|
|
743
|
+
}
|
|
744
|
+
function mapConnectError(err) {
|
|
745
|
+
if (err instanceof AppError) return err;
|
|
746
|
+
const e = asPgError(err);
|
|
747
|
+
const msg = e.message ?? "";
|
|
748
|
+
switch (e.code) {
|
|
749
|
+
case "28P01":
|
|
750
|
+
case "28000":
|
|
751
|
+
return opError("PGX_AUTH_FAILED", { detail: msg }, err);
|
|
752
|
+
case "3D000":
|
|
753
|
+
return opError("PGX_DB_NOT_FOUND", { detail: msg }, err);
|
|
754
|
+
case "ECONNREFUSED":
|
|
755
|
+
case "ENOTFOUND":
|
|
756
|
+
case "EAI_AGAIN":
|
|
757
|
+
case "EHOSTUNREACH":
|
|
758
|
+
return opError("PGX_HOST_UNREACHABLE", { detail: msg }, err);
|
|
759
|
+
case "ETIMEDOUT":
|
|
760
|
+
return opError("PGX_CONN_TIMEOUT", { detail: msg }, err);
|
|
761
|
+
}
|
|
762
|
+
if (/timeout/i.test(msg)) return opError("PGX_CONN_TIMEOUT", { detail: msg }, err);
|
|
763
|
+
if (/self.signed|certificate|verify|CERT_/i.test(msg))
|
|
764
|
+
return opError("PGX_SSL_VERIFY_FAILED", { detail: msg }, err);
|
|
765
|
+
if (/SSL|encryption/i.test(msg)) return opError("PGX_SSL_REQUIRED", { detail: msg }, err);
|
|
766
|
+
return opError("PGX_HOST_UNREACHABLE", { detail: msg }, err);
|
|
767
|
+
}
|
|
768
|
+
function mapQueryError(err) {
|
|
769
|
+
if (err instanceof AppError) return err;
|
|
770
|
+
const e = asPgError(err);
|
|
771
|
+
const msg = e.message ?? "";
|
|
772
|
+
const meta = {};
|
|
773
|
+
if (e.code) meta.sqlState = e.code;
|
|
774
|
+
if (e.position) meta.position = Number(e.position);
|
|
775
|
+
switch (e.code) {
|
|
776
|
+
case "57014":
|
|
777
|
+
return /statement timeout/i.test(msg) ? opError("PGX_STATEMENT_TIMEOUT", { detail: msg, meta }, err) : opError("PGX_QUERY_CANCELED", { detail: msg, meta }, err);
|
|
778
|
+
case "55P03":
|
|
779
|
+
return opError("PGX_LOCK_TIMEOUT", { detail: msg, meta }, err);
|
|
780
|
+
case "42501":
|
|
781
|
+
return opError("PGX_PERMISSION_DENIED", { detail: msg, meta }, err);
|
|
782
|
+
case "42P01":
|
|
783
|
+
return opError("PGX_RELATION_NOT_FOUND", { detail: msg, meta }, err);
|
|
784
|
+
case "28P01":
|
|
785
|
+
case "28000":
|
|
786
|
+
return opError("PGX_AUTH_FAILED", { detail: msg, meta }, err);
|
|
787
|
+
case "3D000":
|
|
788
|
+
return opError("PGX_DB_NOT_FOUND", { detail: msg, meta }, err);
|
|
789
|
+
default:
|
|
790
|
+
return opError("PGX_QUERY_FAILED", { detail: msg, meta }, err);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
var DEFAULT_THRESHOLDS = {
|
|
794
|
+
seqScanRows: 1e5,
|
|
795
|
+
nestedLoopOuterRows: 1e4,
|
|
796
|
+
filterDiscardRatio: 0.9,
|
|
797
|
+
filterRemovedAbs: 1e4,
|
|
798
|
+
misestimateFactor: 10,
|
|
799
|
+
heapFetchRatio: 0.1,
|
|
800
|
+
heapFetchAbs: 1e3,
|
|
801
|
+
correlatedLoops: 1e3,
|
|
802
|
+
jitPct: 25,
|
|
803
|
+
triggerPct: 10,
|
|
804
|
+
lowCacheHitRatio: 0.9,
|
|
805
|
+
limitDiscardRows: 1e4,
|
|
806
|
+
staleStatsModRatio: 0.2
|
|
807
|
+
};
|
|
808
|
+
var DEFAULT_CONFIG = {
|
|
809
|
+
thresholds: { ...DEFAULT_THRESHOLDS },
|
|
810
|
+
rules: {}
|
|
811
|
+
};
|
|
812
|
+
|
|
813
|
+
// src/core/parse-text.ts
|
|
814
|
+
var cap = (w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase();
|
|
815
|
+
var numeric = (v) => {
|
|
816
|
+
const t = v.trim();
|
|
817
|
+
return /^-?\d+(\.\d+)?$/.test(t) ? Number(t) : t;
|
|
818
|
+
};
|
|
819
|
+
function splitList(s) {
|
|
820
|
+
const out = [];
|
|
821
|
+
let depth = 0;
|
|
822
|
+
let cur = "";
|
|
823
|
+
for (const ch of s) {
|
|
824
|
+
if (ch === "(") depth++;
|
|
825
|
+
else if (ch === ")") depth--;
|
|
826
|
+
if (ch === "," && depth === 0) {
|
|
827
|
+
if (cur.trim()) out.push(cur.trim());
|
|
828
|
+
cur = "";
|
|
829
|
+
} else {
|
|
830
|
+
cur += ch;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
if (cur.trim()) out.push(cur.trim());
|
|
834
|
+
return out;
|
|
835
|
+
}
|
|
836
|
+
function splitIntoLines(text) {
|
|
837
|
+
const out = [];
|
|
838
|
+
const lines = text.split(/\r?\n/);
|
|
839
|
+
const count = (s, re) => (s.match(re) || []).length;
|
|
840
|
+
const closingFirst = (s) => {
|
|
841
|
+
const c = s.indexOf(")");
|
|
842
|
+
const o = s.indexOf("(");
|
|
843
|
+
return c !== -1 && c < o;
|
|
844
|
+
};
|
|
845
|
+
const sameIndent = (a, b) => a.search(/\S/) === b.search(/\S/);
|
|
846
|
+
for (const line of lines) {
|
|
847
|
+
const prev = out[out.length - 1];
|
|
848
|
+
if (prev && count(prev, /\)/g) !== count(prev, /\(/g)) {
|
|
849
|
+
out[out.length - 1] += line;
|
|
850
|
+
} else if (/^(?:Total\s+runtime|Planning(\s+time)?|Execution\s+time|Time|Filter|Output|JIT|Trigger|Settings|Serialization)/i.test(
|
|
851
|
+
line
|
|
852
|
+
)) {
|
|
853
|
+
out.push(line);
|
|
854
|
+
} else if (/^\S/.test(line) || /^\s*\(/.test(line) || closingFirst(line)) {
|
|
855
|
+
if (prev) out[out.length - 1] += line;
|
|
856
|
+
else out.push(line);
|
|
857
|
+
} else if (prev && /,\s*$/.test(prev) && !sameIndent(prev, line) && !/^\s*->/i.test(line)) {
|
|
858
|
+
out[out.length - 1] += line;
|
|
859
|
+
} else {
|
|
860
|
+
out.push(line);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
return out;
|
|
864
|
+
}
|
|
865
|
+
var estimation = String.raw`\(cost=(\d+\.\d+)\.\.(\d+\.\d+)\s+rows=(\d+)\s+width=(\d+)\)`;
|
|
866
|
+
var actual = String.raw`(?:actual(?:\stime=(\d+\.\d+)\.\.(\d+\.\d+))?\srows=(\d+(?:\.\d+)?)\sloops=(\d+)|(never\s+executed))`;
|
|
867
|
+
var nodeRe = new RegExp(
|
|
868
|
+
String.raw`^(\s*->\s*|\s*)(Finalize|Simple|Partial)*\s*([^\r\n\t\f\v(]*?)\s*` + String.raw`(?:(?:${estimation}\s+\(${actual}\))|(?:${estimation})|(?:\(${actual}\)))\s*$`
|
|
869
|
+
);
|
|
870
|
+
var subRe = /^((?:Sub|Init)Plan)\s*(?:\d+\s*)?(?:\(returns.*\))?\s*$/;
|
|
871
|
+
var cteRe = /^CTE\s+(\S+)\s*$/;
|
|
872
|
+
var workerRe = /^Worker\s+(\d+):\s+(?:actual(?:\stime=(\d+\.\d+)\.\.(\d+\.\d+))?\srows=(\d+(?:\.\d+)?)\sloops=(\d+)|never\s+executed)(.*)$/;
|
|
873
|
+
var triggerRe = /^Trigger\s+(.*):\s+time=(\d+\.\d+)\s+calls=(\d+)\s*$/;
|
|
874
|
+
var headerRe = /^(QUERY PLAN|-{2,}|#|\(\d+ rows?\))/;
|
|
875
|
+
function splitNodeType(text) {
|
|
876
|
+
let s = text.trim();
|
|
877
|
+
let indexName;
|
|
878
|
+
let relationName;
|
|
879
|
+
let schema;
|
|
880
|
+
let alias;
|
|
881
|
+
const using = s.match(/\susing (\S+)/);
|
|
882
|
+
if (using?.[1]) {
|
|
883
|
+
indexName = using[1];
|
|
884
|
+
s = s.replace(using[0] ?? "", "");
|
|
885
|
+
}
|
|
886
|
+
const on = s.match(/\son (\S+?)(?:\s+(\S+))?\s*$/);
|
|
887
|
+
if (on?.[1]) {
|
|
888
|
+
let rel = on[1];
|
|
889
|
+
alias = on[2];
|
|
890
|
+
const dot = rel.lastIndexOf(".");
|
|
891
|
+
if (dot !== -1) {
|
|
892
|
+
schema = rel.slice(0, dot);
|
|
893
|
+
rel = rel.slice(dot + 1);
|
|
894
|
+
}
|
|
895
|
+
relationName = rel;
|
|
896
|
+
s = s.slice(0, on.index).trim();
|
|
897
|
+
}
|
|
898
|
+
const nodeType = s.replace(/^Parallel\s+/, "").trim();
|
|
899
|
+
if (nodeType === "Bitmap Index Scan" && relationName && !indexName) {
|
|
900
|
+
indexName = relationName;
|
|
901
|
+
relationName = void 0;
|
|
902
|
+
schema = void 0;
|
|
903
|
+
alias = void 0;
|
|
904
|
+
}
|
|
905
|
+
const out = { "Node Type": nodeType };
|
|
906
|
+
if (relationName) out["Relation Name"] = relationName;
|
|
907
|
+
if (indexName) out["Index Name"] = indexName;
|
|
908
|
+
if (schema) out.Schema = schema;
|
|
909
|
+
if (alias && alias !== relationName) out.Alias = alias;
|
|
910
|
+
return out;
|
|
911
|
+
}
|
|
912
|
+
function parseSort(text, node) {
|
|
913
|
+
const m = text.match(/^Sort Method:\s+(.*?)\s+(Memory|Disk):\s+(\S+)kB\s*$/);
|
|
914
|
+
if (!m?.[1] || !m[2] || m[3] === void 0) return false;
|
|
915
|
+
node["Sort Method"] = m[1].trim();
|
|
916
|
+
node["Sort Space Type"] = m[2];
|
|
917
|
+
node["Sort Space Used"] = Number(m[3]);
|
|
918
|
+
return true;
|
|
919
|
+
}
|
|
920
|
+
function parseBuffers(text, node) {
|
|
921
|
+
const m = text.match(/^Buffers:\s+(.*)$/);
|
|
922
|
+
if (!m?.[1]) return false;
|
|
923
|
+
for (const group of m[1].split(/,\s+/)) {
|
|
924
|
+
const g = group.match(/^(shared|temp|local)\s+(.*)$/);
|
|
925
|
+
if (!g?.[1] || g[2] === void 0) continue;
|
|
926
|
+
const type = cap(g[1]);
|
|
927
|
+
for (const kv of g[2].trim().split(/\s+/)) {
|
|
928
|
+
const [method, value] = kv.split("=");
|
|
929
|
+
if (method && value !== void 0) node[`${type} ${cap(method)} Blocks`] = Number(value);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
return true;
|
|
933
|
+
}
|
|
934
|
+
function parseWal(text, node) {
|
|
935
|
+
const m = text.match(/^WAL:\s+(.*)$/);
|
|
936
|
+
if (!m?.[1]) return false;
|
|
937
|
+
for (const kv of m[1].trim().split(/\s+/)) {
|
|
938
|
+
const [k, value] = kv.split("=");
|
|
939
|
+
if (!k || value === void 0) continue;
|
|
940
|
+
node[`WAL ${k === "fpi" ? "FPI" : cap(k)}`] = Number(value);
|
|
941
|
+
}
|
|
942
|
+
return true;
|
|
943
|
+
}
|
|
944
|
+
function parseIoTimings(text, node) {
|
|
945
|
+
const m = text.match(/^I\/O Timings:\s+(.*)$/);
|
|
946
|
+
if (!m?.[1]) return false;
|
|
947
|
+
const read = m[1].match(/(?:^|\s)read=(\d+(?:\.\d+)?)/);
|
|
948
|
+
const write2 = m[1].match(/(?:^|\s)write=(\d+(?:\.\d+)?)/);
|
|
949
|
+
if (read?.[1]) node["I/O Read Time"] = Number(read[1]);
|
|
950
|
+
if (write2?.[1]) node["I/O Write Time"] = Number(write2[1]);
|
|
951
|
+
return true;
|
|
952
|
+
}
|
|
953
|
+
function parseSettings(text) {
|
|
954
|
+
const out = {};
|
|
955
|
+
for (const pair of splitList(text)) {
|
|
956
|
+
const m = pair.match(/^(\S+)\s*=\s*(.*)$/);
|
|
957
|
+
if (m?.[1] && m[2] !== void 0) out[m[1]] = m[2].replace(/^'|'$/g, "");
|
|
958
|
+
}
|
|
959
|
+
return out;
|
|
960
|
+
}
|
|
961
|
+
var LIST_KEYS = /* @__PURE__ */ new Set(["Output", "Sort Key", "Presorted Key", "Group Key"]);
|
|
962
|
+
function parseTextToStatements(input) {
|
|
963
|
+
const statements = [];
|
|
964
|
+
let stmt = null;
|
|
965
|
+
let stack = [];
|
|
966
|
+
let current = null;
|
|
967
|
+
let jit = null;
|
|
968
|
+
const finish = () => {
|
|
969
|
+
if (stmt?.Plan) statements.push(stmt);
|
|
970
|
+
stmt = null;
|
|
971
|
+
stack = [];
|
|
972
|
+
current = null;
|
|
973
|
+
jit = null;
|
|
974
|
+
};
|
|
975
|
+
for (let raw of splitIntoLines(input)) {
|
|
976
|
+
raw = raw.replace(/"\s*$/, "").replace(/^\s*"/, "").replace(/\t/g, " ");
|
|
977
|
+
const depth = raw.match(/^\s*/)?.[0].length ?? 0;
|
|
978
|
+
const line = raw.slice(depth);
|
|
979
|
+
if (line === "" || headerRe.test(line)) {
|
|
980
|
+
if (line === "" && stmt?.Plan) finish();
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
const nodeM = nodeRe.exec(line);
|
|
984
|
+
const subM = subRe.exec(line);
|
|
985
|
+
const cteM = cteRe.exec(line);
|
|
986
|
+
if (nodeM && !subM && !cteM) {
|
|
987
|
+
if (!stmt) stmt = {};
|
|
988
|
+
jit = null;
|
|
989
|
+
const node = { ...splitNodeType(nodeM[3] ?? "") };
|
|
990
|
+
if (nodeM[2]) node["Partial Mode"] = nodeM[2];
|
|
991
|
+
const startup = nodeM[4] ?? nodeM[13];
|
|
992
|
+
const total = nodeM[5] ?? nodeM[14];
|
|
993
|
+
if (startup && total) {
|
|
994
|
+
node["Startup Cost"] = Number(startup);
|
|
995
|
+
node["Total Cost"] = Number(total);
|
|
996
|
+
node["Plan Rows"] = Number(nodeM[6] ?? nodeM[15]);
|
|
997
|
+
node["Plan Width"] = Number(nodeM[7] ?? nodeM[16]);
|
|
998
|
+
}
|
|
999
|
+
const st = nodeM[8] ?? nodeM[17];
|
|
1000
|
+
const tt = nodeM[9] ?? nodeM[18];
|
|
1001
|
+
if (st && tt) {
|
|
1002
|
+
node["Actual Startup Time"] = Number(st);
|
|
1003
|
+
node["Actual Total Time"] = Number(tt);
|
|
1004
|
+
}
|
|
1005
|
+
const rows = nodeM[10] ?? nodeM[19];
|
|
1006
|
+
const loops = nodeM[11] ?? nodeM[20];
|
|
1007
|
+
if (rows && loops) {
|
|
1008
|
+
node["Actual Rows"] = Number(rows);
|
|
1009
|
+
node["Actual Loops"] = Number(loops);
|
|
1010
|
+
}
|
|
1011
|
+
if (nodeM[12] ?? nodeM[21]) {
|
|
1012
|
+
node["Actual Loops"] = 0;
|
|
1013
|
+
node["Actual Rows"] = 0;
|
|
1014
|
+
}
|
|
1015
|
+
stack = stack.filter((f) => f.depth < depth);
|
|
1016
|
+
const parent = stack[stack.length - 1];
|
|
1017
|
+
if (!parent) {
|
|
1018
|
+
stmt.Plan = node;
|
|
1019
|
+
} else {
|
|
1020
|
+
if (parent.rel) {
|
|
1021
|
+
node["Parent Relationship"] = parent.rel;
|
|
1022
|
+
if (parent.name) node["Subplan Name"] = parent.name;
|
|
1023
|
+
}
|
|
1024
|
+
const parentNode = parent.node;
|
|
1025
|
+
if (!parentNode.Plans) parentNode.Plans = [];
|
|
1026
|
+
parentNode.Plans.push(node);
|
|
1027
|
+
}
|
|
1028
|
+
stack.push({ depth, node });
|
|
1029
|
+
current = node;
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
if (subM || cteM) {
|
|
1033
|
+
stack = stack.filter((f) => f.depth < depth);
|
|
1034
|
+
const parent = stack[stack.length - 1];
|
|
1035
|
+
if (!parent) continue;
|
|
1036
|
+
if (cteM?.[1])
|
|
1037
|
+
stack.push({ depth, node: parent.node, rel: "InitPlan", name: `CTE ${cteM[1]}` });
|
|
1038
|
+
else if (subM?.[1])
|
|
1039
|
+
stack.push({
|
|
1040
|
+
depth,
|
|
1041
|
+
node: parent.node,
|
|
1042
|
+
rel: subM[1],
|
|
1043
|
+
name: (subM[0] ?? "").trim()
|
|
1044
|
+
});
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
const workerM = workerRe.exec(line);
|
|
1048
|
+
if (workerM && current) {
|
|
1049
|
+
const worker = { "Worker Number": Number(workerM[1]) };
|
|
1050
|
+
if (workerM[2] && workerM[3]) {
|
|
1051
|
+
worker["Actual Startup Time"] = Number(workerM[2]);
|
|
1052
|
+
worker["Actual Total Time"] = Number(workerM[3]);
|
|
1053
|
+
}
|
|
1054
|
+
if (workerM[4] && workerM[5]) {
|
|
1055
|
+
worker["Actual Rows"] = Number(workerM[4]);
|
|
1056
|
+
worker["Actual Loops"] = Number(workerM[5]);
|
|
1057
|
+
}
|
|
1058
|
+
if (!Array.isArray(current.Workers)) current.Workers = [];
|
|
1059
|
+
current.Workers.push(worker);
|
|
1060
|
+
continue;
|
|
1061
|
+
}
|
|
1062
|
+
const trigM = triggerRe.exec(line);
|
|
1063
|
+
if (trigM && stmt) {
|
|
1064
|
+
if (!Array.isArray(stmt.Triggers)) stmt.Triggers = [];
|
|
1065
|
+
stmt.Triggers.push({
|
|
1066
|
+
"Trigger Name": trigM[1],
|
|
1067
|
+
Time: Number(trigM[2]),
|
|
1068
|
+
Calls: Number(trigM[3])
|
|
1069
|
+
});
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
1072
|
+
const kv = line.match(/^([^:]+):\s*(.*)$/);
|
|
1073
|
+
if (!kv?.[1]) continue;
|
|
1074
|
+
const key = kv[1].trim();
|
|
1075
|
+
const value = (kv[2] ?? "").trim();
|
|
1076
|
+
if (key === "JIT") {
|
|
1077
|
+
jit = {};
|
|
1078
|
+
if (stmt) stmt.JIT = jit;
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
1081
|
+
if (jit) {
|
|
1082
|
+
if (key === "Functions") jit.Functions = Number(value);
|
|
1083
|
+
else if (key === "Timing") {
|
|
1084
|
+
const timing = {};
|
|
1085
|
+
for (const part of value.split(/,\s*/)) {
|
|
1086
|
+
const t = part.match(/^(\S+)\s+(\d+\.\d+)\s*ms/);
|
|
1087
|
+
if (t?.[1]) timing[t[1]] = Number(t[2]);
|
|
1088
|
+
}
|
|
1089
|
+
jit.Timing = timing;
|
|
1090
|
+
}
|
|
1091
|
+
continue;
|
|
1092
|
+
}
|
|
1093
|
+
if (key === "Planning Time") {
|
|
1094
|
+
if (stmt) stmt["Planning Time"] = parseFloat(value);
|
|
1095
|
+
continue;
|
|
1096
|
+
}
|
|
1097
|
+
if (key === "Execution Time" || key === "Total runtime") {
|
|
1098
|
+
if (stmt) stmt["Execution Time"] = parseFloat(value);
|
|
1099
|
+
continue;
|
|
1100
|
+
}
|
|
1101
|
+
if (key === "Settings") {
|
|
1102
|
+
if (stmt) stmt.Settings = parseSettings(value);
|
|
1103
|
+
continue;
|
|
1104
|
+
}
|
|
1105
|
+
if (!current) continue;
|
|
1106
|
+
if (parseSort(line, current) || parseBuffers(line, current) || parseWal(line, current) || parseIoTimings(line, current)) {
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1109
|
+
current[key] = LIST_KEYS.has(key) ? splitList(value) : numeric(value);
|
|
1110
|
+
}
|
|
1111
|
+
finish();
|
|
1112
|
+
return statements;
|
|
1113
|
+
}
|
|
1114
|
+
var PlanNodeSchema = z.looseObject({
|
|
1115
|
+
"Node Type": z.string(),
|
|
1116
|
+
get Plans() {
|
|
1117
|
+
return z.array(PlanNodeSchema).optional();
|
|
1118
|
+
}
|
|
1119
|
+
});
|
|
1120
|
+
var StatementSchema = z.looseObject({
|
|
1121
|
+
Plan: PlanNodeSchema,
|
|
1122
|
+
"Planning Time": z.number().optional(),
|
|
1123
|
+
"Execution Time": z.number().optional(),
|
|
1124
|
+
Triggers: z.array(z.looseObject({})).optional(),
|
|
1125
|
+
JIT: z.looseObject({}).optional(),
|
|
1126
|
+
Settings: z.record(z.string(), z.unknown()).optional()
|
|
1127
|
+
});
|
|
1128
|
+
var ExplainOutputSchema = z.array(StatementSchema).min(1);
|
|
1129
|
+
|
|
1130
|
+
// src/core/parse.ts
|
|
1131
|
+
function num(raw, key) {
|
|
1132
|
+
const v = raw[key];
|
|
1133
|
+
return typeof v === "number" ? v : void 0;
|
|
1134
|
+
}
|
|
1135
|
+
function str(raw, key) {
|
|
1136
|
+
const v = raw[key];
|
|
1137
|
+
return typeof v === "string" ? v : void 0;
|
|
1138
|
+
}
|
|
1139
|
+
function strArray(raw, key) {
|
|
1140
|
+
const v = raw[key];
|
|
1141
|
+
if (Array.isArray(v) && v.every((x) => typeof x === "string")) return v;
|
|
1142
|
+
if (typeof v === "string") return [v];
|
|
1143
|
+
return void 0;
|
|
1144
|
+
}
|
|
1145
|
+
function parseJsonWithLocation(input) {
|
|
1146
|
+
try {
|
|
1147
|
+
return JSON.parse(input);
|
|
1148
|
+
} catch (err) {
|
|
1149
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1150
|
+
let line;
|
|
1151
|
+
let col;
|
|
1152
|
+
const lc = message.match(/line (\d+) column (\d+)/i);
|
|
1153
|
+
if (lc?.[1] && lc[2]) {
|
|
1154
|
+
line = Number(lc[1]);
|
|
1155
|
+
col = Number(lc[2]);
|
|
1156
|
+
} else {
|
|
1157
|
+
const pos = message.match(/position (\d+)/i);
|
|
1158
|
+
if (pos?.[1]) {
|
|
1159
|
+
const offset = Number(pos[1]);
|
|
1160
|
+
const before = input.slice(0, offset);
|
|
1161
|
+
line = before.split("\n").length;
|
|
1162
|
+
col = offset - before.lastIndexOf("\n");
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
const where = line && col ? ` (line ${line}, col ${col})` : "";
|
|
1166
|
+
throw opError(
|
|
1167
|
+
"PGX_MALFORMED_JSON",
|
|
1168
|
+
{
|
|
1169
|
+
detail: `The plan input could not be parsed as JSON${where}: ${message}`,
|
|
1170
|
+
location: line && col ? { kind: "input", line, col } : { kind: "input" }
|
|
1171
|
+
},
|
|
1172
|
+
err
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
function normalizeNode(raw, nextId) {
|
|
1177
|
+
const node = {
|
|
1178
|
+
id: nextId(),
|
|
1179
|
+
nodeType: raw["Node Type"],
|
|
1180
|
+
planRows: num(raw, "Plan Rows") ?? 0,
|
|
1181
|
+
children: [],
|
|
1182
|
+
metrics: {},
|
|
1183
|
+
raw
|
|
1184
|
+
};
|
|
1185
|
+
assign(node, {
|
|
1186
|
+
parentRelationship: str(raw, "Parent Relationship"),
|
|
1187
|
+
subplanName: str(raw, "Subplan Name"),
|
|
1188
|
+
relationName: str(raw, "Relation Name"),
|
|
1189
|
+
schema: str(raw, "Schema"),
|
|
1190
|
+
alias: str(raw, "Alias"),
|
|
1191
|
+
indexName: str(raw, "Index Name"),
|
|
1192
|
+
planWidth: num(raw, "Plan Width"),
|
|
1193
|
+
startupCost: num(raw, "Startup Cost"),
|
|
1194
|
+
totalCost: num(raw, "Total Cost"),
|
|
1195
|
+
actualRows: num(raw, "Actual Rows"),
|
|
1196
|
+
actualLoops: num(raw, "Actual Loops"),
|
|
1197
|
+
actualStartupTime: num(raw, "Actual Startup Time"),
|
|
1198
|
+
actualTotalTime: num(raw, "Actual Total Time"),
|
|
1199
|
+
filter: str(raw, "Filter"),
|
|
1200
|
+
rowsRemovedByFilter: num(raw, "Rows Removed by Filter"),
|
|
1201
|
+
indexCond: str(raw, "Index Cond"),
|
|
1202
|
+
recheckCond: str(raw, "Recheck Cond"),
|
|
1203
|
+
rowsRemovedByIndexRecheck: num(raw, "Rows Removed by Index Recheck"),
|
|
1204
|
+
heapFetches: num(raw, "Heap Fetches"),
|
|
1205
|
+
hashCond: str(raw, "Hash Cond"),
|
|
1206
|
+
joinType: str(raw, "Join Type"),
|
|
1207
|
+
joinFilter: str(raw, "Join Filter"),
|
|
1208
|
+
rowsRemovedByJoinFilter: num(raw, "Rows Removed by Join Filter"),
|
|
1209
|
+
output: strArray(raw, "Output"),
|
|
1210
|
+
sortMethod: str(raw, "Sort Method"),
|
|
1211
|
+
sortSpaceType: str(raw, "Sort Space Type"),
|
|
1212
|
+
sortSpaceUsed: num(raw, "Sort Space Used"),
|
|
1213
|
+
sortKey: strArray(raw, "Sort Key"),
|
|
1214
|
+
hashBuckets: num(raw, "Hash Buckets"),
|
|
1215
|
+
originalHashBuckets: num(raw, "Original Hash Buckets"),
|
|
1216
|
+
hashBatches: num(raw, "Hash Batches"),
|
|
1217
|
+
originalHashBatches: num(raw, "Original Hash Batches"),
|
|
1218
|
+
peakMemoryUsage: num(raw, "Peak Memory Usage"),
|
|
1219
|
+
diskUsage: num(raw, "Disk Usage"),
|
|
1220
|
+
exactHeapBlocks: num(raw, "Exact Heap Blocks"),
|
|
1221
|
+
lossyHeapBlocks: num(raw, "Lossy Heap Blocks"),
|
|
1222
|
+
cacheHits: num(raw, "Cache Hits"),
|
|
1223
|
+
cacheMisses: num(raw, "Cache Misses"),
|
|
1224
|
+
cacheEvictions: num(raw, "Cache Evictions"),
|
|
1225
|
+
cacheOverflows: num(raw, "Cache Overflows"),
|
|
1226
|
+
sharedHitBlocks: num(raw, "Shared Hit Blocks"),
|
|
1227
|
+
sharedReadBlocks: num(raw, "Shared Read Blocks"),
|
|
1228
|
+
sharedDirtiedBlocks: num(raw, "Shared Dirtied Blocks"),
|
|
1229
|
+
sharedWrittenBlocks: num(raw, "Shared Written Blocks"),
|
|
1230
|
+
localHitBlocks: num(raw, "Local Hit Blocks"),
|
|
1231
|
+
localReadBlocks: num(raw, "Local Read Blocks"),
|
|
1232
|
+
tempReadBlocks: num(raw, "Temp Read Blocks"),
|
|
1233
|
+
tempWrittenBlocks: num(raw, "Temp Written Blocks"),
|
|
1234
|
+
ioReadTime: num(raw, "I/O Read Time"),
|
|
1235
|
+
ioWriteTime: num(raw, "I/O Write Time"),
|
|
1236
|
+
workersPlanned: num(raw, "Workers Planned"),
|
|
1237
|
+
workersLaunched: num(raw, "Workers Launched"),
|
|
1238
|
+
walRecords: num(raw, "WAL Records"),
|
|
1239
|
+
walBytes: num(raw, "WAL Bytes"),
|
|
1240
|
+
walFpi: num(raw, "WAL FPI")
|
|
1241
|
+
});
|
|
1242
|
+
const workers = parseWorkers(raw.Workers);
|
|
1243
|
+
if (workers.length) node.workers = workers;
|
|
1244
|
+
const childPlans = raw.Plans;
|
|
1245
|
+
if (Array.isArray(childPlans)) {
|
|
1246
|
+
for (const child of childPlans) {
|
|
1247
|
+
node.children.push(normalizeNode(child, nextId));
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
return node;
|
|
1251
|
+
}
|
|
1252
|
+
function assign(target, fields) {
|
|
1253
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
1254
|
+
if (v !== void 0) target[k] = v;
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
function parseWorkers(raw) {
|
|
1258
|
+
if (!Array.isArray(raw)) return [];
|
|
1259
|
+
const out = [];
|
|
1260
|
+
for (const w of raw) {
|
|
1261
|
+
const r = w;
|
|
1262
|
+
const number = num(r, "Worker Number");
|
|
1263
|
+
if (number === void 0) continue;
|
|
1264
|
+
const stat2 = { number };
|
|
1265
|
+
assign(stat2, {
|
|
1266
|
+
actualRows: num(r, "Actual Rows"),
|
|
1267
|
+
actualLoops: num(r, "Actual Loops"),
|
|
1268
|
+
actualStartupTime: num(r, "Actual Startup Time"),
|
|
1269
|
+
actualTotalTime: num(r, "Actual Total Time")
|
|
1270
|
+
});
|
|
1271
|
+
out.push(stat2);
|
|
1272
|
+
}
|
|
1273
|
+
return out;
|
|
1274
|
+
}
|
|
1275
|
+
function parseTriggers(raw) {
|
|
1276
|
+
if (!Array.isArray(raw)) return [];
|
|
1277
|
+
return raw.map((t) => {
|
|
1278
|
+
const r = t;
|
|
1279
|
+
const info = {};
|
|
1280
|
+
assign(info, {
|
|
1281
|
+
name: str(r, "Trigger Name"),
|
|
1282
|
+
constraintName: str(r, "Constraint Name"),
|
|
1283
|
+
relation: str(r, "Relation"),
|
|
1284
|
+
calls: num(r, "Calls"),
|
|
1285
|
+
time: num(r, "Time")
|
|
1286
|
+
});
|
|
1287
|
+
return info;
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
function parseJit(raw) {
|
|
1291
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
1292
|
+
const r = raw;
|
|
1293
|
+
const timing = r.Timing;
|
|
1294
|
+
const jit = {};
|
|
1295
|
+
const functions = num(r, "Functions");
|
|
1296
|
+
if (functions !== void 0) jit.functions = functions;
|
|
1297
|
+
if (timing && typeof timing === "object") {
|
|
1298
|
+
jit.timing = {
|
|
1299
|
+
total: num(timing, "Total"),
|
|
1300
|
+
generation: num(timing, "Generation"),
|
|
1301
|
+
inlining: num(timing, "Inlining"),
|
|
1302
|
+
optimization: num(timing, "Optimization"),
|
|
1303
|
+
emission: num(timing, "Emission")
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
return jit;
|
|
1307
|
+
}
|
|
1308
|
+
function statementToTree(stmt) {
|
|
1309
|
+
let id = 0;
|
|
1310
|
+
const root = normalizeNode(stmt.Plan, () => id++);
|
|
1311
|
+
const hasAnalyze = root.actualLoops !== void 0 || stmt["Execution Time"] !== void 0;
|
|
1312
|
+
const hasBuffers = root.sharedHitBlocks !== void 0 || root.sharedReadBlocks !== void 0;
|
|
1313
|
+
const tree = {
|
|
1314
|
+
root,
|
|
1315
|
+
triggers: parseTriggers(stmt.Triggers),
|
|
1316
|
+
hasAnalyze,
|
|
1317
|
+
hasBuffers,
|
|
1318
|
+
raw: stmt.Plan
|
|
1319
|
+
};
|
|
1320
|
+
if (typeof stmt["Planning Time"] === "number") tree.planningTime = stmt["Planning Time"];
|
|
1321
|
+
if (typeof stmt["Execution Time"] === "number") tree.executionTime = stmt["Execution Time"];
|
|
1322
|
+
const serialization = stmt.Serialization;
|
|
1323
|
+
if (serialization && typeof serialization === "object") {
|
|
1324
|
+
const t = num(serialization, "Time");
|
|
1325
|
+
if (t !== void 0) tree.serializationTime = t;
|
|
1326
|
+
}
|
|
1327
|
+
const jit = parseJit(stmt.JIT);
|
|
1328
|
+
if (jit) tree.jit = jit;
|
|
1329
|
+
if (stmt.Settings) tree.settings = stmt.Settings;
|
|
1330
|
+
return tree;
|
|
1331
|
+
}
|
|
1332
|
+
function parseExplain(input) {
|
|
1333
|
+
return /^\s*[[{]/.test(input) ? parseExplainJson(input) : parseExplainText(input);
|
|
1334
|
+
}
|
|
1335
|
+
function parseExplainText(input) {
|
|
1336
|
+
return parseTextToStatements(input).map(statementToTree);
|
|
1337
|
+
}
|
|
1338
|
+
function parseExplainJson(input) {
|
|
1339
|
+
const json = parseJsonWithLocation(input);
|
|
1340
|
+
let candidate = json;
|
|
1341
|
+
if (json && typeof json === "object" && !Array.isArray(json)) {
|
|
1342
|
+
const obj = json;
|
|
1343
|
+
candidate = "Plan" in obj ? [obj] : "Node Type" in obj ? [{ Plan: obj }] : json;
|
|
1344
|
+
}
|
|
1345
|
+
const result = ExplainOutputSchema.safeParse(candidate);
|
|
1346
|
+
if (!result.success) {
|
|
1347
|
+
throw opError("PGX_UNEXPECTED_PLAN_SHAPE", {
|
|
1348
|
+
detail: `The JSON is valid but is not an EXPLAIN plan: ${result.error.issues[0]?.message ?? "missing 'Plan' node"}.`,
|
|
1349
|
+
location: { kind: "input" }
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1352
|
+
return result.data.map((stmt) => statementToTree(stmt));
|
|
1353
|
+
}
|
|
1354
|
+
function walk(node, visit) {
|
|
1355
|
+
visit(node);
|
|
1356
|
+
for (const child of node.children) walk(child, visit);
|
|
1357
|
+
}
|
|
1358
|
+
function flatten(node) {
|
|
1359
|
+
const out = [];
|
|
1360
|
+
walk(node, (n) => out.push(n));
|
|
1361
|
+
return out;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// src/core/metrics.ts
|
|
1365
|
+
function computeMetrics(tree) {
|
|
1366
|
+
walk(tree.root, (node) => {
|
|
1367
|
+
const { actualRows, actualLoops, actualTotalTime } = node;
|
|
1368
|
+
if (actualRows !== void 0 && actualLoops !== void 0) {
|
|
1369
|
+
node.metrics.totalRows = actualRows * actualLoops;
|
|
1370
|
+
}
|
|
1371
|
+
if (actualTotalTime !== void 0 && actualLoops !== void 0) {
|
|
1372
|
+
node.metrics.inclusiveMs = actualTotalTime * actualLoops;
|
|
1373
|
+
}
|
|
1374
|
+
if (node.metrics.totalRows !== void 0) {
|
|
1375
|
+
const est = Math.max(node.planRows, 1);
|
|
1376
|
+
const act = Math.max(node.metrics.totalRows, 1);
|
|
1377
|
+
node.metrics.estimateFactor = est >= act ? est / act : act / est;
|
|
1378
|
+
node.metrics.estimateDirection = node.planRows > node.metrics.totalRows ? "over" : node.metrics.totalRows > node.planRows ? "under" : "accurate";
|
|
1379
|
+
}
|
|
1380
|
+
const hit = node.sharedHitBlocks ?? 0;
|
|
1381
|
+
const read = node.sharedReadBlocks ?? 0;
|
|
1382
|
+
node.metrics.cacheHitRatio = hit + read > 0 ? hit / (hit + read) : null;
|
|
1383
|
+
if (node.rowsRemovedByFilter !== void 0 && actualLoops !== void 0) {
|
|
1384
|
+
const removed = node.rowsRemovedByFilter * actualLoops;
|
|
1385
|
+
const kept = node.metrics.totalRows ?? 0;
|
|
1386
|
+
const denom = removed + kept;
|
|
1387
|
+
if (denom > 0) node.metrics.filterDiscardRatio = removed / denom;
|
|
1388
|
+
}
|
|
1389
|
+
if (node.lossyHeapBlocks !== void 0) {
|
|
1390
|
+
const lossy = node.lossyHeapBlocks;
|
|
1391
|
+
const exact = node.exactHeapBlocks ?? 0;
|
|
1392
|
+
const denom = lossy + exact;
|
|
1393
|
+
if (denom > 0) node.metrics.lossyRatio = lossy / denom;
|
|
1394
|
+
}
|
|
1395
|
+
});
|
|
1396
|
+
walk(tree.root, (node) => {
|
|
1397
|
+
if (node.metrics.inclusiveMs === void 0) return;
|
|
1398
|
+
let childrenMs = 0;
|
|
1399
|
+
for (const child of node.children) childrenMs += child.metrics.inclusiveMs ?? 0;
|
|
1400
|
+
node.metrics.selfMs = Math.max(node.metrics.inclusiveMs - childrenMs, 0);
|
|
1401
|
+
});
|
|
1402
|
+
const totalMs = executionMs(tree);
|
|
1403
|
+
if (totalMs && totalMs > 0) {
|
|
1404
|
+
walk(tree.root, (node) => {
|
|
1405
|
+
if (node.metrics.selfMs !== void 0) {
|
|
1406
|
+
node.metrics.pctOfTotal = 100 * node.metrics.selfMs / totalMs;
|
|
1407
|
+
}
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
function executionMs(tree) {
|
|
1412
|
+
return tree.executionTime ?? tree.root.metrics.inclusiveMs;
|
|
1413
|
+
}
|
|
1414
|
+
function bottlenecks(tree, n = 5) {
|
|
1415
|
+
return flatten(tree.root).filter((node) => node.metrics.selfMs !== void 0).sort((a, b) => (b.metrics.selfMs ?? 0) - (a.metrics.selfMs ?? 0)).slice(0, n);
|
|
1416
|
+
}
|
|
1417
|
+
function aggregateStats(tree) {
|
|
1418
|
+
const total = executionMs(tree) ?? 0;
|
|
1419
|
+
const groupBy = (keyOf) => {
|
|
1420
|
+
const acc = /* @__PURE__ */ new Map();
|
|
1421
|
+
for (const n of flatten(tree.root)) {
|
|
1422
|
+
const key = keyOf(n);
|
|
1423
|
+
if (!key) continue;
|
|
1424
|
+
const e = acc.get(key) ?? { count: 0, selfMs: 0 };
|
|
1425
|
+
e.count++;
|
|
1426
|
+
e.selfMs += n.metrics.selfMs ?? 0;
|
|
1427
|
+
acc.set(key, e);
|
|
1428
|
+
}
|
|
1429
|
+
return [...acc.entries()].map(([key, e]) => ({
|
|
1430
|
+
key,
|
|
1431
|
+
count: e.count,
|
|
1432
|
+
selfMs: e.selfMs,
|
|
1433
|
+
pctOfTotal: total > 0 ? 100 * e.selfMs / total : 0
|
|
1434
|
+
})).sort((a, b) => b.selfMs - a.selfMs || b.count - a.count);
|
|
1435
|
+
};
|
|
1436
|
+
return {
|
|
1437
|
+
byNodeType: groupBy((n) => n.nodeType),
|
|
1438
|
+
byRelation: groupBy((n) => n.relationName),
|
|
1439
|
+
byIndex: groupBy((n) => n.indexName)
|
|
1440
|
+
};
|
|
1441
|
+
}
|
|
1442
|
+
function nodeLabel(node) {
|
|
1443
|
+
let label = node.nodeType;
|
|
1444
|
+
if (node.indexName && node.relationName)
|
|
1445
|
+
label += ` using ${node.indexName} on ${node.relationName}`;
|
|
1446
|
+
else if (node.relationName) label += ` on ${node.relationName}`;
|
|
1447
|
+
if (node.alias && node.alias !== node.relationName) label += ` (${node.alias})`;
|
|
1448
|
+
return label;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
// src/util/format.ts
|
|
1452
|
+
var BLOCK_BYTES = 8192;
|
|
1453
|
+
function fmtInt(n) {
|
|
1454
|
+
return Math.round(n).toLocaleString("en-US");
|
|
1455
|
+
}
|
|
1456
|
+
function fmtMs(ms) {
|
|
1457
|
+
if (ms < 1) return `${ms.toFixed(3)} ms`;
|
|
1458
|
+
if (ms < 1e3) return `${ms.toFixed(1)} ms`;
|
|
1459
|
+
if (ms < 6e4) return `${(ms / 1e3).toFixed(2)} s`;
|
|
1460
|
+
const min = Math.floor(ms / 6e4);
|
|
1461
|
+
const sec = (ms % 6e4 / 1e3).toFixed(0);
|
|
1462
|
+
return `${min}m ${sec}s`;
|
|
1463
|
+
}
|
|
1464
|
+
function fmtPct(fraction0to100) {
|
|
1465
|
+
return `${fraction0to100.toFixed(1)}%`;
|
|
1466
|
+
}
|
|
1467
|
+
function fmtKiB(kib) {
|
|
1468
|
+
return fmtBytes(kib * 1024);
|
|
1469
|
+
}
|
|
1470
|
+
function fmtBlocks(blocks) {
|
|
1471
|
+
return `${fmtInt(blocks)} blk (${fmtBytes(blocks * BLOCK_BYTES)})`;
|
|
1472
|
+
}
|
|
1473
|
+
function fmtBytes(bytes) {
|
|
1474
|
+
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
|
1475
|
+
let v = bytes;
|
|
1476
|
+
let i = 0;
|
|
1477
|
+
while (v >= 1024 && i < units.length - 1) {
|
|
1478
|
+
v /= 1024;
|
|
1479
|
+
i++;
|
|
1480
|
+
}
|
|
1481
|
+
const s = i === 0 ? String(Math.round(v)) : v.toFixed(1);
|
|
1482
|
+
return `${s} ${units[i]}`;
|
|
1483
|
+
}
|
|
1484
|
+
function roundUpMiB(kib, stepMiB = 4) {
|
|
1485
|
+
const mib = Math.ceil(kib / 1024 / stepMiB) * stepMiB;
|
|
1486
|
+
return `${Math.max(mib, stepMiB)}MB`;
|
|
1487
|
+
}
|
|
1488
|
+
var UNICODE_TREE = { branch: "\u251C\u2500 ", last: "\u2514\u2500 ", vert: "\u2502 ", space: " " };
|
|
1489
|
+
var ASCII_TREE = { branch: "+- ", last: "`- ", vert: "| ", space: " " };
|
|
1490
|
+
|
|
1491
|
+
// src/advisor/rules/util.ts
|
|
1492
|
+
var DOCS2 = "https://www.postgresql.org/docs/current";
|
|
1493
|
+
function locationOf(node) {
|
|
1494
|
+
const loc = { kind: "node", nodeId: node.id, nodeType: node.nodeType };
|
|
1495
|
+
if (node.relationName) loc.relation = node.relationName;
|
|
1496
|
+
return loc;
|
|
1497
|
+
}
|
|
1498
|
+
function makeFinding(rule, ctx, node, parts) {
|
|
1499
|
+
const d = {
|
|
1500
|
+
code: rule.id,
|
|
1501
|
+
domain: "plan",
|
|
1502
|
+
severity: ctx.severityOf(rule.id, parts.severity ?? rule.defaultSeverity),
|
|
1503
|
+
title: parts.title,
|
|
1504
|
+
detail: parts.detail,
|
|
1505
|
+
cause: parts.cause,
|
|
1506
|
+
remediation: parts.remediation,
|
|
1507
|
+
location: locationOf(node)
|
|
1508
|
+
};
|
|
1509
|
+
if (parts.docsUrl) d.docsUrl = parts.docsUrl;
|
|
1510
|
+
if (parts.meta) d.meta = parts.meta;
|
|
1511
|
+
return d;
|
|
1512
|
+
}
|
|
1513
|
+
function outerChild(node) {
|
|
1514
|
+
return node.children[0];
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// src/advisor/rules/bitmap-lossy.ts
|
|
1518
|
+
var bitmapLossy = {
|
|
1519
|
+
id: "PGX_BITMAP_LOSSY",
|
|
1520
|
+
title: "Lossy bitmap heap scan",
|
|
1521
|
+
defaultSeverity: "info",
|
|
1522
|
+
requiresAnalyze: true,
|
|
1523
|
+
check(node, ctx) {
|
|
1524
|
+
if (node.nodeType !== "Bitmap Heap Scan") return [];
|
|
1525
|
+
const lossy = node.lossyHeapBlocks ?? 0;
|
|
1526
|
+
if (lossy <= 0) return [];
|
|
1527
|
+
const exact = node.exactHeapBlocks ?? 0;
|
|
1528
|
+
const rel = node.relationName ?? "the table";
|
|
1529
|
+
const rechecked = node.rowsRemovedByIndexRecheck ?? 0;
|
|
1530
|
+
const recheckNote = rechecked > 0 ? ` The recheck discarded ${fmtInt(rechecked)} extra rows that the lossy bitmap could not exclude.` : "";
|
|
1531
|
+
return [
|
|
1532
|
+
makeFinding(bitmapLossy, ctx, node, {
|
|
1533
|
+
title: `Lossy bitmap heap scan on ${rel} (${fmtInt(lossy)} lossy blocks)`,
|
|
1534
|
+
detail: `The bitmap for ${rel} held ${fmtInt(lossy)} lossy (page-granularity) blocks alongside ${fmtInt(
|
|
1535
|
+
exact
|
|
1536
|
+
)} exact blocks, so Postgres re-checked the index condition against every tuple on the lossy pages.${recheckNote}`,
|
|
1537
|
+
cause: "The exact (per-tuple) bitmap did not fit in work_mem, so Postgres fell back to storing whole heap pages and recheck the index condition while reading them \u2014 more heap I/O and CPU than an exact bitmap.",
|
|
1538
|
+
remediation: {
|
|
1539
|
+
summary: `Raise work_mem for this session so the bitmap stays exact (no lossy blocks, no rechecks) on ${rel}; alternatively make the index condition more selective or add a composite index so the bitmap is smaller.`,
|
|
1540
|
+
steps: [
|
|
1541
|
+
"Increase work_mem for the session, then re-run EXPLAIN (ANALYZE) and confirm Lossy Heap Blocks drops to 0.",
|
|
1542
|
+
"If raising work_mem is undesirable, make the index condition more selective (a more selective leading column or a composite index over the filtered columns) so fewer heap pages enter the bitmap."
|
|
1543
|
+
],
|
|
1544
|
+
commands: [
|
|
1545
|
+
{
|
|
1546
|
+
label: "Give this session more work_mem",
|
|
1547
|
+
sql: "SET work_mem = '<X>MB';"
|
|
1548
|
+
},
|
|
1549
|
+
{
|
|
1550
|
+
label: "Or shrink the bitmap with a more selective index",
|
|
1551
|
+
sql: `CREATE INDEX ON ${rel} (<more selective / composite columns>);`
|
|
1552
|
+
}
|
|
1553
|
+
]
|
|
1554
|
+
},
|
|
1555
|
+
docsUrl: `${DOCS2}/runtime-config-resource.html#GUC-WORK-MEM`,
|
|
1556
|
+
meta: { lossyBlocks: lossy, exactBlocks: exact }
|
|
1557
|
+
})
|
|
1558
|
+
];
|
|
1559
|
+
}
|
|
1560
|
+
};
|
|
1561
|
+
|
|
1562
|
+
// src/advisor/rules/cartesian-product.ts
|
|
1563
|
+
function rowsOf(node) {
|
|
1564
|
+
return node.metrics.totalRows ?? node.planRows;
|
|
1565
|
+
}
|
|
1566
|
+
var cartesianProduct = {
|
|
1567
|
+
id: "PGX_CARTESIAN_PRODUCT",
|
|
1568
|
+
title: "Cartesian product (missing join condition)",
|
|
1569
|
+
defaultSeverity: "error",
|
|
1570
|
+
check(node, ctx) {
|
|
1571
|
+
if (node.nodeType !== "Nested Loop") return [];
|
|
1572
|
+
if (node.joinFilter) return [];
|
|
1573
|
+
let inner = node.children[1];
|
|
1574
|
+
if (!inner) return [];
|
|
1575
|
+
while ((inner.nodeType === "Memoize" || inner.nodeType === "Materialize") && inner.children[0]) {
|
|
1576
|
+
inner = inner.children[0];
|
|
1577
|
+
}
|
|
1578
|
+
if (inner.indexCond || inner.recheckCond) return [];
|
|
1579
|
+
const outer = node.children[0];
|
|
1580
|
+
if (!outer) return [];
|
|
1581
|
+
const outerRows = rowsOf(outer);
|
|
1582
|
+
const innerRows = rowsOf(inner);
|
|
1583
|
+
if (outerRows <= 1 || innerRows <= 1) return [];
|
|
1584
|
+
const estimated = node.metrics.totalRows === void 0;
|
|
1585
|
+
const product = fmtInt(outerRows * innerRows);
|
|
1586
|
+
return [
|
|
1587
|
+
makeFinding(cartesianProduct, ctx, node, {
|
|
1588
|
+
title: `Cartesian product: Nested Loop with no join condition (~${product}${estimated ? " est." : ""} rows)`,
|
|
1589
|
+
detail: `The Nested Loop has no Join Filter and the inner side has no Index Cond or Recheck Cond, so each of ${fmtInt(
|
|
1590
|
+
outerRows
|
|
1591
|
+
)} outer rows is paired with every one of ${fmtInt(innerRows)} inner rows${estimated ? " (estimated \u2014 run with ANALYZE for actuals)" : ""}.`,
|
|
1592
|
+
cause: "No predicate links the two relations, so Postgres can only emit the full cross product. This usually means an ON or WHERE join condition was omitted (e.g. a comma join across tables).",
|
|
1593
|
+
remediation: {
|
|
1594
|
+
summary: "Add the missing join condition linking the two tables on their key columns (e.g. ON a.id = b.a_id). If a cross product is truly intended, make it explicit with CROSS JOIN and bound it with a LIMIT or aggregation.",
|
|
1595
|
+
steps: [
|
|
1596
|
+
"Find the two relations feeding this Nested Loop in your query.",
|
|
1597
|
+
"Add an ON (or WHERE) clause matching their key columns so the loop becomes selective.",
|
|
1598
|
+
"If you really want every combination, write CROSS JOIN explicitly and cap the size with LIMIT or an aggregate."
|
|
1599
|
+
],
|
|
1600
|
+
commands: [
|
|
1601
|
+
{
|
|
1602
|
+
label: "Add the join predicate",
|
|
1603
|
+
sql: "SELECT ...\nFROM <outer_table> a\nJOIN <inner_table> b ON a.<key> = b.<key>;"
|
|
1604
|
+
},
|
|
1605
|
+
{
|
|
1606
|
+
label: "Or make the cross join explicit and bounded",
|
|
1607
|
+
sql: "SELECT ...\nFROM <outer_table> a\nCROSS JOIN <inner_table> b\nLIMIT <n>;"
|
|
1608
|
+
}
|
|
1609
|
+
]
|
|
1610
|
+
},
|
|
1611
|
+
docsUrl: `${DOCS2}/queries-table-expressions.html#QUERIES-JOIN`,
|
|
1612
|
+
meta: { outerRows: Math.round(outerRows), innerRows: Math.round(innerRows) }
|
|
1613
|
+
})
|
|
1614
|
+
];
|
|
1615
|
+
}
|
|
1616
|
+
};
|
|
1617
|
+
|
|
1618
|
+
// src/advisor/rules/correlated-subplan.ts
|
|
1619
|
+
var correlatedSubplan = {
|
|
1620
|
+
id: "PGX_CORRELATED_SUBPLAN",
|
|
1621
|
+
title: "Correlated subplan re-executed per row",
|
|
1622
|
+
defaultSeverity: "warn",
|
|
1623
|
+
requiresAnalyze: true,
|
|
1624
|
+
check(node, ctx) {
|
|
1625
|
+
const isSubplan = node.parentRelationship === "SubPlan" || (node.subplanName?.startsWith("SubPlan") ?? false);
|
|
1626
|
+
if (!isSubplan) return [];
|
|
1627
|
+
const loops = node.actualLoops ?? 0;
|
|
1628
|
+
if (loops <= ctx.thresholds.correlatedLoops) return [];
|
|
1629
|
+
const name = node.subplanName ?? "the subplan";
|
|
1630
|
+
return [
|
|
1631
|
+
makeFinding(correlatedSubplan, ctx, node, {
|
|
1632
|
+
title: `Correlated ${name} re-executed ${fmtInt(loops)} times`,
|
|
1633
|
+
detail: `${name} ran ${fmtInt(loops)} times \u2014 once per outer row \u2014 instead of being evaluated a single time.`,
|
|
1634
|
+
cause: "The subquery references a column from the enclosing query, so the planner cannot pull it out of the per-row loop and re-runs the whole subplan for every outer row.",
|
|
1635
|
+
remediation: {
|
|
1636
|
+
summary: `De-correlate the subquery: rewrite it as a JOIN or LATERAL join, or hoist it into a CTE/derived table evaluated once, then index the correlation key so the rewrite stays cheap.`,
|
|
1637
|
+
steps: [
|
|
1638
|
+
"Identify the outer column the subquery references (the correlation key).",
|
|
1639
|
+
"For a scalar subquery in SELECT/WHERE, rewrite it as a LEFT JOIN to a grouped derived table, or a LATERAL join when it returns per-row results.",
|
|
1640
|
+
"For EXISTS/IN, prefer the semi-join form (EXISTS / = ANY) the planner can de-correlate into a single hash/merge join.",
|
|
1641
|
+
"Add an index on the correlation key so the resulting JOIN does not fall back to the same per-row cost.",
|
|
1642
|
+
// Before (correlated, runs once per outer row):
|
|
1643
|
+
// SELECT o.id, (SELECT count(*) FROM events e WHERE e.order_id = o.id) AS n FROM orders o;
|
|
1644
|
+
// After (evaluated once, joined):
|
|
1645
|
+
// SELECT o.id, COALESCE(e.n, 0) AS n
|
|
1646
|
+
// FROM orders o
|
|
1647
|
+
// LEFT JOIN (SELECT order_id, count(*) AS n FROM events GROUP BY order_id) e
|
|
1648
|
+
// ON e.order_id = o.id;
|
|
1649
|
+
"See the before/after sketch: SELECT (SELECT count(*) FROM events e WHERE e.order_id = o.id) becomes a LEFT JOIN to (SELECT order_id, count(*) FROM events GROUP BY order_id)."
|
|
1650
|
+
],
|
|
1651
|
+
commands: [
|
|
1652
|
+
{
|
|
1653
|
+
label: "Index the correlation key so the de-correlated JOIN stays cheap",
|
|
1654
|
+
sql: "CREATE INDEX ON <subquery table> (<correlation key column>);"
|
|
1655
|
+
}
|
|
1656
|
+
]
|
|
1657
|
+
},
|
|
1658
|
+
docsUrl: `${DOCS2}/queries-table-expressions.html#QUERIES-LATERAL`,
|
|
1659
|
+
meta: { loops }
|
|
1660
|
+
})
|
|
1661
|
+
];
|
|
1662
|
+
}
|
|
1663
|
+
};
|
|
1664
|
+
|
|
1665
|
+
// src/advisor/rules/could-be-index-only.ts
|
|
1666
|
+
var couldBeIndexOnly = {
|
|
1667
|
+
id: "PGX_COULD_BE_INDEX_ONLY",
|
|
1668
|
+
title: "Index scan may be eligible for index-only",
|
|
1669
|
+
defaultSeverity: "info",
|
|
1670
|
+
check(node, ctx) {
|
|
1671
|
+
if (node.nodeType !== "Index Scan") return [];
|
|
1672
|
+
if (!node.indexName) return [];
|
|
1673
|
+
if (node.filter) return [];
|
|
1674
|
+
if (!node.output || node.output.length === 0) return [];
|
|
1675
|
+
if (node.output.length > 4) return [];
|
|
1676
|
+
const rel = node.relationName ?? "the table";
|
|
1677
|
+
const cols = node.output;
|
|
1678
|
+
const colList = cols.join(", ");
|
|
1679
|
+
const includeCols = cols.join(", ");
|
|
1680
|
+
return [
|
|
1681
|
+
makeFinding(couldBeIndexOnly, ctx, node, {
|
|
1682
|
+
title: `Index Scan using ${node.indexName} on ${rel} may be eligible for index-only`,
|
|
1683
|
+
detail: `This Index Scan projects only ${cols.length} column${cols.length === 1 ? "" : "s"} (${colList}) and applies no residual filter, so its predicate is fully resolved by ${node.indexName}. If that index also covers the selected columns, Postgres could use an Index Only Scan and skip the heap entirely.`,
|
|
1684
|
+
cause: "An Index Scan still visits the table heap to fetch the projected columns. When every selected column is contained in the index (as a key or INCLUDE column) and the visibility map is current, Postgres can answer from the index alone (Index Only Scan).",
|
|
1685
|
+
remediation: {
|
|
1686
|
+
summary: `Add the selected columns (${includeCols}) to ${node.indexName} as INCLUDE columns so it covers the query, then keep the visibility map current with VACUUM so Postgres can switch ${rel} to an Index Only Scan.`,
|
|
1687
|
+
steps: [
|
|
1688
|
+
"Confirm which columns the index already covers (\\d <index> in psql) \u2014 this hint assumes VERBOSE Output and cannot read the index definition.",
|
|
1689
|
+
"If any projected column is missing, recreate the index with those columns as INCLUDE (non-key) columns.",
|
|
1690
|
+
"Run VACUUM so the visibility map is set; Index Only Scans fall back to heap fetches on pages not marked all-visible."
|
|
1691
|
+
],
|
|
1692
|
+
commands: [
|
|
1693
|
+
{
|
|
1694
|
+
label: "Create a covering index",
|
|
1695
|
+
sql: `CREATE INDEX ON ${rel} (<key columns from the Index Cond>) INCLUDE (${includeCols});`
|
|
1696
|
+
},
|
|
1697
|
+
{
|
|
1698
|
+
label: "Keep the visibility map current",
|
|
1699
|
+
sql: `VACUUM ${rel};`
|
|
1700
|
+
}
|
|
1701
|
+
]
|
|
1702
|
+
},
|
|
1703
|
+
docsUrl: `${DOCS2}/indexes-index-only-scans.html`,
|
|
1704
|
+
meta: { outputColumns: cols.length }
|
|
1705
|
+
})
|
|
1706
|
+
];
|
|
1707
|
+
}
|
|
1708
|
+
};
|
|
1709
|
+
|
|
1710
|
+
// src/advisor/rules/filter-could-be-index-cond.ts
|
|
1711
|
+
var filterCouldBeIndexCond = {
|
|
1712
|
+
id: "PGX_FILTER_COULD_BE_INDEX_COND",
|
|
1713
|
+
title: "Filter could be an index condition",
|
|
1714
|
+
defaultSeverity: "info",
|
|
1715
|
+
requiresAnalyze: true,
|
|
1716
|
+
check(node, ctx) {
|
|
1717
|
+
const indexed = node.nodeType === "Index Scan" || node.nodeType === "Index Only Scan" || node.nodeType === "Bitmap Heap Scan";
|
|
1718
|
+
if (!indexed) return [];
|
|
1719
|
+
if (!node.filter) return [];
|
|
1720
|
+
if (!node.indexCond && !node.recheckCond) return [];
|
|
1721
|
+
if ((node.rowsRemovedByFilter ?? 0) <= 0) return [];
|
|
1722
|
+
const rel = node.relationName ?? "the table";
|
|
1723
|
+
const cond = node.indexCond ?? node.recheckCond ?? "";
|
|
1724
|
+
const loops = node.actualLoops ?? 1;
|
|
1725
|
+
const removed = (node.rowsRemovedByFilter ?? 0) * loops;
|
|
1726
|
+
return [
|
|
1727
|
+
makeFinding(filterCouldBeIndexCond, ctx, node, {
|
|
1728
|
+
title: `Residual filter on ${rel} could be an index condition`,
|
|
1729
|
+
detail: `${node.nodeType} on ${rel} used the index for ${cond} but then applied Filter ${node.filter} to the fetched rows, discarding ${fmtInt(removed)} of them.`,
|
|
1730
|
+
cause: `The Filter columns are not part of the index, so Postgres must fetch each row the index matched and re-check the predicate in the executor instead of skipping non-matching entries during the index traversal.`,
|
|
1731
|
+
remediation: {
|
|
1732
|
+
summary: `Extend the index on ${rel} to include the Filter columns from ${node.filter} as trailing key columns, so the predicate is applied as an Index Cond during traversal instead of as a post-fetch Filter.`,
|
|
1733
|
+
steps: [
|
|
1734
|
+
`Confirm the Filter ${node.filter} is sargable \u2014 no functions or implicit casts wrapping the column.`,
|
|
1735
|
+
"Append the filter columns after the existing key columns so the index still serves the original lookup.",
|
|
1736
|
+
"Re-run EXPLAIN (ANALYZE) and check the Filter moved into the Index Cond with no Rows Removed by Filter."
|
|
1737
|
+
],
|
|
1738
|
+
commands: [
|
|
1739
|
+
{
|
|
1740
|
+
label: "Extend the index with the filter columns",
|
|
1741
|
+
sql: `CREATE INDEX ON ${rel} (<existing key columns>, <filter columns>);`
|
|
1742
|
+
}
|
|
1743
|
+
]
|
|
1744
|
+
},
|
|
1745
|
+
docsUrl: `${DOCS2}/indexes-multicolumn.html`,
|
|
1746
|
+
meta: { rowsRemovedByFilter: Math.round(removed) }
|
|
1747
|
+
})
|
|
1748
|
+
];
|
|
1749
|
+
}
|
|
1750
|
+
};
|
|
1751
|
+
|
|
1752
|
+
// src/advisor/rules/hash-spill-disk.ts
|
|
1753
|
+
var hashSpillDisk = {
|
|
1754
|
+
id: "PGX_HASH_SPILL_DISK",
|
|
1755
|
+
title: "Hash join spilled to disk",
|
|
1756
|
+
defaultSeverity: "warn",
|
|
1757
|
+
requiresAnalyze: true,
|
|
1758
|
+
check(node, ctx) {
|
|
1759
|
+
if (node.nodeType !== "Hash") return [];
|
|
1760
|
+
const hashBatches = node.hashBatches ?? 1;
|
|
1761
|
+
if (hashBatches <= 1) return [];
|
|
1762
|
+
const originalHashBatches = node.originalHashBatches ?? hashBatches;
|
|
1763
|
+
const repartitioned = hashBatches > originalHashBatches;
|
|
1764
|
+
const peakKiB = node.peakMemoryUsage ?? 0;
|
|
1765
|
+
const diskKiB = node.diskUsage ?? 0;
|
|
1766
|
+
const workMemRecommended = roundUpMiB((peakKiB + diskKiB) * 1.2);
|
|
1767
|
+
return [
|
|
1768
|
+
makeFinding(hashSpillDisk, ctx, node, {
|
|
1769
|
+
title: `Hash build side spilled to disk (${fmtInt(hashBatches)} batches)`,
|
|
1770
|
+
detail: `The hash table was split into ${fmtInt(hashBatches)} batches${repartitioned ? ` (up from ${fmtInt(originalHashBatches)} planned)` : ""}, so the build side did not fit in work_mem and overflowed to temporary files${diskKiB > 0 ? ` (${fmtKiB(diskKiB)} written to disk)` : ""}.`,
|
|
1771
|
+
cause: repartitioned ? "Postgres had to add batches at runtime because the build side was larger than estimated \u2014 usually a row underestimate sized work_mem too small." : "The build (hash) side was larger than work_mem, forcing the hash join to partition it across disk-backed batches.",
|
|
1772
|
+
remediation: {
|
|
1773
|
+
summary: `Raise work_mem to about ${workMemRecommended} so the build side fits in a single batch, and make sure the SMALLER input is the hash/build side (a wrong build side usually comes from a row underestimate \u2014 fix the stats). Reducing build-side rows also avoids the spill.`,
|
|
1774
|
+
steps: [
|
|
1775
|
+
`Set work_mem high enough to hold the build side in one batch (~${workMemRecommended} here) at session or role scope, not globally \u2014 every sort/hash node can use work_mem, so a global bump can exhaust memory.`,
|
|
1776
|
+
"Confirm the smaller relation is on the build (Hash) side; if Postgres chose the larger side, a row underestimate is likely \u2014 re-run ANALYZE or raise statistics targets so the planner picks the smaller build side.",
|
|
1777
|
+
"Alternatively, filter or aggregate the build side earlier so fewer rows need to be hashed."
|
|
1778
|
+
],
|
|
1779
|
+
commands: [
|
|
1780
|
+
{
|
|
1781
|
+
label: "Raise work_mem for this session",
|
|
1782
|
+
sql: `SET work_mem = '${workMemRecommended}';`
|
|
1783
|
+
},
|
|
1784
|
+
{
|
|
1785
|
+
label: "Or set it for a specific role",
|
|
1786
|
+
sql: `ALTER ROLE <role> SET work_mem = '${workMemRecommended}';`
|
|
1787
|
+
},
|
|
1788
|
+
{
|
|
1789
|
+
label: "Refresh planner statistics on the build-side table",
|
|
1790
|
+
sql: "ANALYZE <build_side_table>;"
|
|
1791
|
+
}
|
|
1792
|
+
]
|
|
1793
|
+
},
|
|
1794
|
+
docsUrl: `${DOCS2}/runtime-config-resource.html#GUC-WORK-MEM`,
|
|
1795
|
+
meta: { hashBatches, workMemRecommended }
|
|
1796
|
+
})
|
|
1797
|
+
];
|
|
1798
|
+
}
|
|
1799
|
+
};
|
|
1800
|
+
|
|
1801
|
+
// src/advisor/rules/high-filter-discard.ts
|
|
1802
|
+
var highFilterDiscard = {
|
|
1803
|
+
id: "PGX_HIGH_FILTER_DISCARD",
|
|
1804
|
+
title: "Filter discards most rows read",
|
|
1805
|
+
defaultSeverity: "warn",
|
|
1806
|
+
requiresAnalyze: true,
|
|
1807
|
+
check(node, ctx) {
|
|
1808
|
+
const ratio = node.metrics.filterDiscardRatio;
|
|
1809
|
+
if (ratio === void 0 || ratio <= ctx.thresholds.filterDiscardRatio) return [];
|
|
1810
|
+
const removed = (node.rowsRemovedByFilter ?? 0) * (node.actualLoops ?? 1);
|
|
1811
|
+
if (removed <= ctx.thresholds.filterRemovedAbs) return [];
|
|
1812
|
+
const rel = node.relationName ?? "the table";
|
|
1813
|
+
const kept = node.metrics.totalRows ?? 0;
|
|
1814
|
+
const discardPct = ratio * 100;
|
|
1815
|
+
const filter = node.filter ?? "the filter predicate";
|
|
1816
|
+
return [
|
|
1817
|
+
makeFinding(highFilterDiscard, ctx, node, {
|
|
1818
|
+
title: `Filter on ${rel} discards ${fmtPct(discardPct)} of rows read`,
|
|
1819
|
+
detail: `Postgres read this node's rows then dropped ${fmtInt(removed)} of them (${fmtPct(
|
|
1820
|
+
discardPct
|
|
1821
|
+
)}), keeping only ${fmtInt(kept)}, via the post-read filter ${filter}.`,
|
|
1822
|
+
cause: `The predicate ${filter} is evaluated as a Filter after the rows are fetched, so every discarded row was still read and examined \u2014 work no index condition narrowed.`,
|
|
1823
|
+
remediation: {
|
|
1824
|
+
summary: `Move ${filter} into an index on ${rel} so the predicate becomes an Index Cond instead of a post-read Filter, letting Postgres skip the non-matching rows. For a low-cardinality predicate, a partial index keyed on the discarded condition is smaller and faster.`,
|
|
1825
|
+
steps: [
|
|
1826
|
+
"Identify the column(s) referenced by the filter above.",
|
|
1827
|
+
"Ensure the predicate is sargable (no function-wrapping or implicit casts on the indexed column).",
|
|
1828
|
+
"Use a plain index when the columns are selective across queries; use a partial index when the same constant predicate is always applied."
|
|
1829
|
+
],
|
|
1830
|
+
commands: [
|
|
1831
|
+
{
|
|
1832
|
+
label: "Index the filter columns",
|
|
1833
|
+
sql: `CREATE INDEX ON ${rel} (<filter columns>);`
|
|
1834
|
+
},
|
|
1835
|
+
{
|
|
1836
|
+
label: "Or a partial index for a fixed low-cardinality predicate",
|
|
1837
|
+
sql: `CREATE INDEX ON ${rel} (<filter columns>) WHERE <predicate>;`
|
|
1838
|
+
}
|
|
1839
|
+
]
|
|
1840
|
+
},
|
|
1841
|
+
docsUrl: `${DOCS2}/indexes-partial.html`,
|
|
1842
|
+
meta: { discardPct: Math.round(discardPct) }
|
|
1843
|
+
})
|
|
1844
|
+
];
|
|
1845
|
+
}
|
|
1846
|
+
};
|
|
1847
|
+
|
|
1848
|
+
// src/advisor/rules/index-only-heap-fetches.ts
|
|
1849
|
+
var indexOnlyHeapFetches = {
|
|
1850
|
+
id: "PGX_INDEX_ONLY_HEAP_FETCHES",
|
|
1851
|
+
title: "Index-only scan with heap fetches",
|
|
1852
|
+
defaultSeverity: "info",
|
|
1853
|
+
requiresAnalyze: true,
|
|
1854
|
+
check(node, ctx) {
|
|
1855
|
+
if (node.nodeType !== "Index Only Scan") return [];
|
|
1856
|
+
const heapFetches = node.heapFetches ?? 0;
|
|
1857
|
+
if (heapFetches <= 0) return [];
|
|
1858
|
+
const rows = Math.max(node.metrics.totalRows ?? 1, 1);
|
|
1859
|
+
const ratio = heapFetches / rows;
|
|
1860
|
+
if (ratio <= ctx.thresholds.heapFetchRatio && heapFetches <= ctx.thresholds.heapFetchAbs) {
|
|
1861
|
+
return [];
|
|
1862
|
+
}
|
|
1863
|
+
const rel = node.relationName ?? "the table";
|
|
1864
|
+
return [
|
|
1865
|
+
makeFinding(indexOnlyHeapFetches, ctx, node, {
|
|
1866
|
+
title: `Index-only scan on ${rel} did ${fmtInt(heapFetches)} heap fetches`,
|
|
1867
|
+
detail: `The index-only scan on ${rel} fell back to the heap ${fmtInt(heapFetches)} times for ${fmtInt(
|
|
1868
|
+
rows
|
|
1869
|
+
)} rows produced. Each fetch is an extra table page read the index-only path was meant to avoid.`,
|
|
1870
|
+
cause: `Heap fetches happen when the visibility map does not mark the pages as all-visible, so Postgres must read the table row to confirm visibility. This usually means ${rel} has not been vacuumed recently enough for its write/update rate.`,
|
|
1871
|
+
remediation: {
|
|
1872
|
+
summary: `Run VACUUM (or VACUUM ANALYZE) on ${rel} to refresh the visibility map so the index-only scan can skip the heap. For a high-churn table, lower autovacuum_vacuum_scale_factor so autovacuum keeps the map current.`,
|
|
1873
|
+
steps: [
|
|
1874
|
+
`VACUUM ${rel} to update the visibility map; add ANALYZE if statistics are also stale.`,
|
|
1875
|
+
"If heap fetches keep returning, the table is updated faster than autovacuum runs \u2014 make autovacuum more aggressive on it.",
|
|
1876
|
+
"Confirm the scan stays index-only afterwards by re-running EXPLAIN (ANALYZE) and checking Heap Fetches drops toward 0."
|
|
1877
|
+
],
|
|
1878
|
+
commands: [
|
|
1879
|
+
{
|
|
1880
|
+
label: "Refresh the visibility map",
|
|
1881
|
+
sql: `VACUUM (ANALYZE) ${rel};`
|
|
1882
|
+
},
|
|
1883
|
+
{
|
|
1884
|
+
label: "Keep the map current on a high-churn table",
|
|
1885
|
+
sql: `ALTER TABLE ${rel} SET (autovacuum_vacuum_scale_factor = 0.02);`
|
|
1886
|
+
}
|
|
1887
|
+
]
|
|
1888
|
+
},
|
|
1889
|
+
docsUrl: `${DOCS2}/indexes-index-only-scans.html`,
|
|
1890
|
+
meta: { heapFetches }
|
|
1891
|
+
})
|
|
1892
|
+
];
|
|
1893
|
+
}
|
|
1894
|
+
};
|
|
1895
|
+
|
|
1896
|
+
// src/advisor/rules/limit-large-offset.ts
|
|
1897
|
+
var limitLargeOffset = {
|
|
1898
|
+
id: "PGX_LIMIT_LARGE_OFFSET",
|
|
1899
|
+
title: "LIMIT discards a large prefix (OFFSET pagination)",
|
|
1900
|
+
defaultSeverity: "warn",
|
|
1901
|
+
requiresAnalyze: true,
|
|
1902
|
+
check(node, ctx) {
|
|
1903
|
+
if (node.nodeType !== "Limit") return [];
|
|
1904
|
+
const child = outerChild(node);
|
|
1905
|
+
const emitted = node.metrics.totalRows;
|
|
1906
|
+
const produced = child?.metrics.totalRows;
|
|
1907
|
+
if (emitted === void 0 || produced === void 0) return [];
|
|
1908
|
+
const discarded = produced - emitted;
|
|
1909
|
+
if (discarded < ctx.thresholds.limitDiscardRows) return [];
|
|
1910
|
+
const rel = child?.relationName ?? "the input";
|
|
1911
|
+
return [
|
|
1912
|
+
makeFinding(limitLargeOffset, ctx, node, {
|
|
1913
|
+
title: `LIMIT discarded ${fmtInt(discarded)} rows before returning ${fmtInt(emitted)}`,
|
|
1914
|
+
detail: `The plan produced ${fmtInt(produced)} rows from ${rel} but the Limit node returned only ${fmtInt(emitted)} \u2014 ${fmtInt(discarded)} rows were generated just to be skipped.`,
|
|
1915
|
+
cause: "OFFSET-style pagination makes Postgres compute and discard every row before the requested page, so deep pages get progressively slower (page N costs O(N)).",
|
|
1916
|
+
remediation: {
|
|
1917
|
+
summary: "Switch to keyset (seek) pagination: filter on the last-seen sort key instead of skipping rows, and keep an index on the sort key so each page is a direct index seek.",
|
|
1918
|
+
steps: [
|
|
1919
|
+
"Order by a unique (or tie-broken) key, e.g. ORDER BY created_at, id.",
|
|
1920
|
+
"Pass the last row's key from the previous page instead of an OFFSET.",
|
|
1921
|
+
"Index the sort key so the WHERE clause seeks directly to the page start."
|
|
1922
|
+
],
|
|
1923
|
+
commands: [
|
|
1924
|
+
{
|
|
1925
|
+
label: "Keyset pagination instead of OFFSET",
|
|
1926
|
+
sql: "SELECT \u2026 FROM t WHERE (created_at, id) > ($last_created_at, $last_id) ORDER BY created_at, id LIMIT 50;"
|
|
1927
|
+
}
|
|
1928
|
+
]
|
|
1929
|
+
},
|
|
1930
|
+
docsUrl: `${DOCS2}/queries-limit.html`,
|
|
1931
|
+
meta: { discarded: Math.round(discarded), emitted: Math.round(emitted) }
|
|
1932
|
+
})
|
|
1933
|
+
];
|
|
1934
|
+
}
|
|
1935
|
+
};
|
|
1936
|
+
|
|
1937
|
+
// src/advisor/rules/low-cache-hit.ts
|
|
1938
|
+
var MIN_READ_BLOCKS = 1e3;
|
|
1939
|
+
var lowCacheHit = {
|
|
1940
|
+
id: "PGX_LOW_CACHE_HIT",
|
|
1941
|
+
title: "Low cache hit ratio (heavy disk reads)",
|
|
1942
|
+
defaultSeverity: "info",
|
|
1943
|
+
requiresBuffers: true,
|
|
1944
|
+
check(node, ctx) {
|
|
1945
|
+
const ratio = node.metrics.cacheHitRatio;
|
|
1946
|
+
const readBlocks = node.sharedReadBlocks ?? 0;
|
|
1947
|
+
if (ratio == null) return [];
|
|
1948
|
+
if (ratio >= ctx.thresholds.lowCacheHitRatio) return [];
|
|
1949
|
+
if (readBlocks <= MIN_READ_BLOCKS) return [];
|
|
1950
|
+
const label = nodeLabel(node);
|
|
1951
|
+
const rel = node.relationName;
|
|
1952
|
+
const ratioPct = ratio * 100;
|
|
1953
|
+
return [
|
|
1954
|
+
makeFinding(lowCacheHit, ctx, node, {
|
|
1955
|
+
title: `Low cache hit ratio at ${label} (${fmtPct(ratioPct)})`,
|
|
1956
|
+
detail: `${label} served only ${fmtPct(ratioPct)} of its shared-buffer accesses from cache, reading ${fmtBlocks(readBlocks)} from disk.`,
|
|
1957
|
+
cause: "The pages this node needed were not resident in shared_buffers, so PostgreSQL had to read them from disk. On a first run this is an expected cold cache; if it persists, the working set is larger than the cache or the scan touches more pages than necessary.",
|
|
1958
|
+
remediation: {
|
|
1959
|
+
summary: `Re-run the query to check whether this is just a cold cache \u2014 the ratio should climb on a warm run. If it stays low, the working set exceeds shared_buffers: size shared_buffers/effective_cache_size to your RAM, or add a selective index on ${rel ?? "the scanned relation"} so far fewer pages are read.`,
|
|
1960
|
+
steps: [
|
|
1961
|
+
"Run the same EXPLAIN (ANALYZE, BUFFERS) a second time; a much higher hit ratio means the first run was a cold cache and no action is needed.",
|
|
1962
|
+
"If the ratio stays low, check whether shared_buffers (and effective_cache_size for planner costing) are sized to the machine's RAM.",
|
|
1963
|
+
"If the node reads far more pages than the rows it returns, add a selective index so only matching pages are fetched."
|
|
1964
|
+
],
|
|
1965
|
+
commands: [
|
|
1966
|
+
{
|
|
1967
|
+
label: "Inspect current buffer-cache sizing",
|
|
1968
|
+
sql: "SHOW shared_buffers; SHOW effective_cache_size;"
|
|
1969
|
+
},
|
|
1970
|
+
{
|
|
1971
|
+
label: "Reduce pages read with a selective index",
|
|
1972
|
+
sql: `CREATE INDEX ON ${rel ?? "<table>"} (<predicate columns>);`
|
|
1973
|
+
}
|
|
1974
|
+
]
|
|
1975
|
+
},
|
|
1976
|
+
docsUrl: `${DOCS2}/runtime-config-resource.html#GUC-SHARED-BUFFERS`,
|
|
1977
|
+
meta: { cacheHitPct: Math.round(ratioPct * 10) / 10, readBlocks }
|
|
1978
|
+
})
|
|
1979
|
+
];
|
|
1980
|
+
}
|
|
1981
|
+
};
|
|
1982
|
+
|
|
1983
|
+
// src/advisor/rules/memoize-evictions.ts
|
|
1984
|
+
var memoizeEvictions = {
|
|
1985
|
+
id: "PGX_MEMOIZE_EVICTIONS",
|
|
1986
|
+
title: "Memoize cache is thrashing",
|
|
1987
|
+
defaultSeverity: "warn",
|
|
1988
|
+
requiresAnalyze: true,
|
|
1989
|
+
check(node, ctx) {
|
|
1990
|
+
if (node.nodeType !== "Memoize") return [];
|
|
1991
|
+
const hits = node.cacheHits ?? 0;
|
|
1992
|
+
const evictions = node.cacheEvictions ?? 0;
|
|
1993
|
+
const overflows = node.cacheOverflows ?? 0;
|
|
1994
|
+
const thrashing = evictions > hits;
|
|
1995
|
+
if (!thrashing && overflows === 0) return [];
|
|
1996
|
+
return [
|
|
1997
|
+
makeFinding(memoizeEvictions, ctx, node, {
|
|
1998
|
+
title: overflows > 0 ? `Memoize cache overflowed ${fmtInt(overflows)} time(s)` : `Memoize evicted ${fmtInt(evictions)} entries against ${fmtInt(hits)} hits`,
|
|
1999
|
+
detail: `The Memoize cache recorded ${fmtInt(hits)} hits, ${fmtInt(node.cacheMisses ?? 0)} misses, ${fmtInt(evictions)} evictions, and ${fmtInt(overflows)} overflows \u2014 entries are being thrown away before they can be reused.`,
|
|
2000
|
+
cause: "The distinct key values do not fit in the memory Memoize is allowed (derived from work_mem \xD7 hash_mem_multiplier), so the cache churns and the node degenerates into a slower re-executing inner side.",
|
|
2001
|
+
remediation: {
|
|
2002
|
+
summary: "Give the session more cache memory (work_mem / hash_mem_multiplier) so the key space fits, or reduce the number of distinct keys flowing into the Memoize.",
|
|
2003
|
+
steps: [
|
|
2004
|
+
"Estimate the distinct keys: the planner sizes the cache from ndistinct of the join key.",
|
|
2005
|
+
"Raise work_mem (or hash_mem_multiplier on PG 15+) for this workload and re-run.",
|
|
2006
|
+
"If the key space is genuinely huge, an index on the inner side may beat Memoize \u2014 compare with enable_memoize = off."
|
|
2007
|
+
],
|
|
2008
|
+
commands: [
|
|
2009
|
+
{ label: "More cache memory for this session", sql: "SET work_mem = '64MB';" },
|
|
2010
|
+
{
|
|
2011
|
+
label: "Compare the plan without Memoize",
|
|
2012
|
+
sql: "SET enable_memoize = off; EXPLAIN ANALYZE <query>;"
|
|
2013
|
+
}
|
|
2014
|
+
]
|
|
2015
|
+
},
|
|
2016
|
+
docsUrl: `${DOCS2}/runtime-config-resource.html`,
|
|
2017
|
+
meta: { hits, evictions, overflows }
|
|
2018
|
+
})
|
|
2019
|
+
];
|
|
2020
|
+
}
|
|
2021
|
+
};
|
|
2022
|
+
|
|
2023
|
+
// src/advisor/rules/nested-loop-large-outer.ts
|
|
2024
|
+
var nestedLoopLargeOuter = {
|
|
2025
|
+
id: "PGX_NESTED_LOOP_LARGE_OUTER",
|
|
2026
|
+
title: "Nested loop with a large outer side",
|
|
2027
|
+
defaultSeverity: "warn",
|
|
2028
|
+
requiresAnalyze: true,
|
|
2029
|
+
check(node, ctx) {
|
|
2030
|
+
if (node.nodeType !== "Nested Loop") return [];
|
|
2031
|
+
const outer = outerChild(node);
|
|
2032
|
+
const outerRows = outer?.metrics.totalRows;
|
|
2033
|
+
if (outerRows === void 0 || outerRows <= ctx.thresholds.nestedLoopOuterRows) return [];
|
|
2034
|
+
const outerLabel = outer ? nodeLabel(outer) : "the outer side";
|
|
2035
|
+
const inner = node.children[1];
|
|
2036
|
+
const innerLabel = inner ? nodeLabel(inner) : "the inner side";
|
|
2037
|
+
const innerCond = inner?.indexCond ?? inner?.joinFilter ?? inner?.filter ?? node.joinFilter;
|
|
2038
|
+
const misestimated = outer?.metrics.estimateDirection === "under";
|
|
2039
|
+
return [
|
|
2040
|
+
makeFinding(nestedLoopLargeOuter, ctx, node, {
|
|
2041
|
+
title: `Nested loop driven by ${fmtInt(outerRows)} outer rows (${outerLabel})`,
|
|
2042
|
+
detail: `The nested loop's outer side (${outerLabel}) produced ${fmtInt(
|
|
2043
|
+
outerRows
|
|
2044
|
+
)} rows, so its inner side (${innerLabel}) is re-executed roughly that many times.`,
|
|
2045
|
+
cause: misestimated ? `The planner expected far fewer outer rows than the ${fmtInt(
|
|
2046
|
+
outerRows
|
|
2047
|
+
)} that actually came back, so it chose a per-row nested loop where a single hash/merge join would have been cheaper.` : `A nested loop probes the inner side once per outer row; with ${fmtInt(
|
|
2048
|
+
outerRows
|
|
2049
|
+
)} outer rows that is ${fmtInt(outerRows)} inner executions.`,
|
|
2050
|
+
remediation: {
|
|
2051
|
+
summary: `Fix the outer-side row estimate first \u2014 re-ANALYZE ${outer?.relationName ?? "the driving table"}, raise its column statistics, or add extended statistics \u2014 so the planner switches to a hash or merge join. If the estimate is already accurate, add an index on the inner join key (${innerCond ?? "<inner join column>"}) so each of the ${fmtInt(outerRows)} probes is cheap.`,
|
|
2052
|
+
steps: [
|
|
2053
|
+
"Compare the outer node's estimated vs actual rows: a large gap means the estimate is the real problem.",
|
|
2054
|
+
"Refresh statistics so the planner sees the true cardinality and can prefer a hash/merge join.",
|
|
2055
|
+
"If columns are correlated, create extended (multivariate) statistics on them.",
|
|
2056
|
+
"If estimates are already correct, index the inner join key so each probe is an index lookup, not a scan.",
|
|
2057
|
+
"As a last resort to confirm the diagnosis, test with `SET enable_nestloop = off` for this query only."
|
|
2058
|
+
],
|
|
2059
|
+
commands: [
|
|
2060
|
+
{
|
|
2061
|
+
label: "Refresh planner statistics on the driving table",
|
|
2062
|
+
sql: `ANALYZE ${outer?.relationName ?? "<outer table>"};`
|
|
2063
|
+
},
|
|
2064
|
+
{
|
|
2065
|
+
label: "Increase statistics target on the misestimated column, then re-ANALYZE",
|
|
2066
|
+
sql: `ALTER TABLE ${outer?.relationName ?? "<outer table>"} ALTER COLUMN <column> SET STATISTICS 1000;
|
|
2067
|
+
ANALYZE ${outer?.relationName ?? "<outer table>"};`
|
|
2068
|
+
},
|
|
2069
|
+
{
|
|
2070
|
+
label: "Add extended statistics for correlated columns",
|
|
2071
|
+
sql: `CREATE STATISTICS ${outer?.relationName ?? "<outer table>"}_stats (dependencies, ndistinct) ON <col_a>, <col_b> FROM ${outer?.relationName ?? "<outer table>"};
|
|
2072
|
+
ANALYZE ${outer?.relationName ?? "<outer table>"};`
|
|
2073
|
+
},
|
|
2074
|
+
{
|
|
2075
|
+
label: "If estimates are correct, index the inner join key",
|
|
2076
|
+
sql: `CREATE INDEX ON ${inner?.relationName ?? "<inner table>"} (<inner join column>);`
|
|
2077
|
+
},
|
|
2078
|
+
{
|
|
2079
|
+
label: "Confirm the diagnosis by disabling nested loops for this query only",
|
|
2080
|
+
sql: "SET enable_nestloop = off;"
|
|
2081
|
+
}
|
|
2082
|
+
]
|
|
2083
|
+
},
|
|
2084
|
+
docsUrl: `${DOCS2}/runtime-config-query.html`,
|
|
2085
|
+
meta: { outerRows: Math.round(outerRows) }
|
|
2086
|
+
})
|
|
2087
|
+
];
|
|
2088
|
+
}
|
|
2089
|
+
};
|
|
2090
|
+
|
|
2091
|
+
// src/advisor/rules/row-misestimate.ts
|
|
2092
|
+
var rowMisestimate = {
|
|
2093
|
+
id: "PGX_ROW_MISESTIMATE",
|
|
2094
|
+
title: "Row count misestimate",
|
|
2095
|
+
defaultSeverity: "info",
|
|
2096
|
+
requiresAnalyze: true,
|
|
2097
|
+
check(node, ctx) {
|
|
2098
|
+
const { estimateFactor, estimateDirection, totalRows } = node.metrics;
|
|
2099
|
+
if (estimateFactor === void 0) return [];
|
|
2100
|
+
if (estimateFactor < ctx.thresholds.misestimateFactor) return [];
|
|
2101
|
+
if (estimateDirection === void 0 || estimateDirection === "accurate") return [];
|
|
2102
|
+
const rows = Math.max(totalRows ?? 0, node.planRows);
|
|
2103
|
+
if (rows < 100) return [];
|
|
2104
|
+
const factor = Math.round(estimateFactor);
|
|
2105
|
+
const rel = node.relationName;
|
|
2106
|
+
const onRel = rel ? ` on ${rel}` : "";
|
|
2107
|
+
const target = rel ?? "the underlying table";
|
|
2108
|
+
const under = estimateDirection === "under";
|
|
2109
|
+
const direction = under ? "underestimate" : "overestimate";
|
|
2110
|
+
const actual2 = totalRows ?? 0;
|
|
2111
|
+
const detail = `Postgres estimated ${fmtInt(node.planRows)} rows but ${fmtInt(actual2)} were produced \u2014 a ${fmtInt(factor)}x ${direction}${onRel}.`;
|
|
2112
|
+
return [
|
|
2113
|
+
makeFinding(rowMisestimate, ctx, node, {
|
|
2114
|
+
// Severity: underestimates are the dangerous ones (under-sized joins/memory).
|
|
2115
|
+
severity: under ? "warn" : "info",
|
|
2116
|
+
title: `${fmtInt(factor)}x row ${direction}${onRel}`,
|
|
2117
|
+
detail,
|
|
2118
|
+
cause: "The planner's row estimate is based on statistics that are stale, missing, or too coarse for this predicate (e.g. correlated columns the planner treats as independent).",
|
|
2119
|
+
remediation: {
|
|
2120
|
+
summary: `Refresh and sharpen statistics for ${target}: run ANALYZE ${rel ?? "<relation>"}, raise per-column statistics targets on the predicate columns, and add extended statistics for correlated columns so the planner estimates rows correctly.${under ? " Underestimates feeding a nested loop or hash join are the highest priority \u2014 fix these first." : ""}`,
|
|
2121
|
+
steps: [
|
|
2122
|
+
`Refresh table statistics first; this alone often fixes the estimate.`,
|
|
2123
|
+
`If the column has a skewed/uneven distribution, raise its statistics target and re-ANALYZE.`,
|
|
2124
|
+
`If the predicate spans multiple correlated columns, create extended statistics so the planner stops assuming independence.`
|
|
2125
|
+
],
|
|
2126
|
+
commands: [
|
|
2127
|
+
{
|
|
2128
|
+
label: "Refresh statistics",
|
|
2129
|
+
sql: `ANALYZE ${rel ?? "<relation>"};`
|
|
2130
|
+
},
|
|
2131
|
+
{
|
|
2132
|
+
label: "Raise per-column statistics target",
|
|
2133
|
+
sql: `ALTER TABLE ${rel ?? "<relation>"} ALTER COLUMN <column> SET STATISTICS 1000;
|
|
2134
|
+
ANALYZE ${rel ?? "<relation>"};`
|
|
2135
|
+
},
|
|
2136
|
+
{
|
|
2137
|
+
label: "Add extended statistics for correlated columns",
|
|
2138
|
+
sql: `CREATE STATISTICS <stats_name> (dependencies, ndistinct) ON <col_a>, <col_b> FROM ${rel ?? "<relation>"};
|
|
2139
|
+
ANALYZE ${rel ?? "<relation>"};`
|
|
2140
|
+
}
|
|
2141
|
+
]
|
|
2142
|
+
},
|
|
2143
|
+
docsUrl: `${DOCS2}/planner-stats.html`,
|
|
2144
|
+
meta: {
|
|
2145
|
+
estimatedRows: Math.round(node.planRows),
|
|
2146
|
+
actualRows: Math.round(actual2),
|
|
2147
|
+
factor,
|
|
2148
|
+
direction: estimateDirection
|
|
2149
|
+
}
|
|
2150
|
+
})
|
|
2151
|
+
];
|
|
2152
|
+
}
|
|
2153
|
+
};
|
|
2154
|
+
|
|
2155
|
+
// src/advisor/rules/seq-scan-large.ts
|
|
2156
|
+
var seqScanLarge = {
|
|
2157
|
+
id: "PGX_SEQ_SCAN_LARGE",
|
|
2158
|
+
title: "Sequential scan on a large table",
|
|
2159
|
+
defaultSeverity: "warn",
|
|
2160
|
+
check(node, ctx) {
|
|
2161
|
+
if (node.nodeType !== "Seq Scan") return [];
|
|
2162
|
+
const rows = node.metrics.totalRows ?? node.planRows;
|
|
2163
|
+
if (rows < ctx.thresholds.seqScanRows) return [];
|
|
2164
|
+
const rel = node.relationName ?? "the table";
|
|
2165
|
+
const estimated = node.metrics.totalRows === void 0;
|
|
2166
|
+
const filterCols = node.filter ? ` matching ${node.filter}` : "";
|
|
2167
|
+
return [
|
|
2168
|
+
makeFinding(seqScanLarge, ctx, node, {
|
|
2169
|
+
title: `Sequential scan on ${rel} (${fmtInt(rows)}${estimated ? " est." : ""} rows)`,
|
|
2170
|
+
detail: `Postgres read ${rel} sequentially, scanning roughly ${fmtInt(rows)} rows${estimated ? " (estimated \u2014 run with ANALYZE for actuals)" : ""}.`,
|
|
2171
|
+
cause: node.filter ? `A row filter (${node.filter}) is applied after reading every row, so no index narrowed the scan.` : "No index condition narrowed the scan, so the whole relation was read.",
|
|
2172
|
+
remediation: {
|
|
2173
|
+
summary: `Add an index covering the WHERE/JOIN predicate on ${rel} so Postgres can skip non-matching rows. If the query genuinely needs most of the table, the seq scan is correct \u2014 reduce the rows touched instead.`,
|
|
2174
|
+
steps: [
|
|
2175
|
+
"Identify the selective columns in the WHERE/JOIN predicate.",
|
|
2176
|
+
"Ensure they are sargable (no function-wrapping or implicit casts on the column).",
|
|
2177
|
+
"If selectivity is low, a partial index (WHERE \u2026) may be better."
|
|
2178
|
+
],
|
|
2179
|
+
commands: [
|
|
2180
|
+
{
|
|
2181
|
+
label: "Index the predicate columns",
|
|
2182
|
+
sql: `CREATE INDEX ON ${rel} (<predicate columns>)${filterCols ? " -- columns from the filter above" : ""};`
|
|
2183
|
+
}
|
|
2184
|
+
]
|
|
2185
|
+
},
|
|
2186
|
+
docsUrl: `${DOCS2}/indexes-intro.html`,
|
|
2187
|
+
meta: { rows: Math.round(rows) }
|
|
2188
|
+
})
|
|
2189
|
+
];
|
|
2190
|
+
}
|
|
2191
|
+
};
|
|
2192
|
+
|
|
2193
|
+
// src/advisor/rules/significant-jit.ts
|
|
2194
|
+
var significantJit = {
|
|
2195
|
+
id: "PGX_SIGNIFICANT_JIT",
|
|
2196
|
+
title: "JIT compilation dominates execution",
|
|
2197
|
+
defaultSeverity: "info",
|
|
2198
|
+
requiresAnalyze: true,
|
|
2199
|
+
check(node, ctx) {
|
|
2200
|
+
if (node !== ctx.tree.root) return [];
|
|
2201
|
+
const t = ctx.tree.jit?.timing;
|
|
2202
|
+
const jitTotal = t?.total ?? (t?.generation ?? 0) + (t?.inlining ?? 0) + (t?.optimization ?? 0) + (t?.emission ?? 0);
|
|
2203
|
+
const execMs = executionMs(ctx.tree);
|
|
2204
|
+
if (!execMs || jitTotal <= 0) return [];
|
|
2205
|
+
const jitPct = 100 * jitTotal / execMs;
|
|
2206
|
+
if (jitPct <= ctx.thresholds.jitPct) return [];
|
|
2207
|
+
return [
|
|
2208
|
+
makeFinding(significantJit, ctx, node, {
|
|
2209
|
+
title: `JIT compilation took ${fmtMs(jitTotal)} (${fmtPct(jitPct)} of execution)`,
|
|
2210
|
+
detail: `JIT spent ${fmtMs(jitTotal)} generating, optimizing, and emitting machine code, out of ${fmtMs(
|
|
2211
|
+
execMs
|
|
2212
|
+
)} total execution time. The compilation overhead outweighs the runtime it saved.`,
|
|
2213
|
+
cause: "The plan's estimated cost crossed jit_above_cost, so Postgres JIT-compiled the query \u2014 but the query is too short for compilation to pay off, often because a row overestimate inflated the cost.",
|
|
2214
|
+
remediation: {
|
|
2215
|
+
summary: "Raise jit_above_cost (and jit_inline_above_cost / jit_optimize_above_cost) so short queries skip JIT, or disable JIT for this session with SET jit = off. Then investigate why the cost estimate is high enough to trigger JIT \u2014 frequently a row overestimate fixable with ANALYZE.",
|
|
2216
|
+
steps: [
|
|
2217
|
+
"Confirm the query is consistently short-running before tuning \u2014 JIT pays off on long, CPU-bound queries.",
|
|
2218
|
+
"Raise jit_above_cost above this plan's total cost so similar queries skip JIT entirely.",
|
|
2219
|
+
"If only inlining/optimization are expensive, raise jit_inline_above_cost / jit_optimize_above_cost instead of disabling JIT.",
|
|
2220
|
+
"Check the planner's row estimates against actuals \u2014 an overestimate that inflates cost is the usual reason a cheap query triggers JIT; run ANALYZE on the relations involved."
|
|
2221
|
+
],
|
|
2222
|
+
commands: [
|
|
2223
|
+
{ label: "Disable JIT for this session", sql: "SET jit = off;" },
|
|
2224
|
+
{
|
|
2225
|
+
label: "Raise the JIT cost thresholds",
|
|
2226
|
+
sql: "SET jit_above_cost = <above this plan's total cost>;\nSET jit_inline_above_cost = <higher>;\nSET jit_optimize_above_cost = <higher>;"
|
|
2227
|
+
},
|
|
2228
|
+
{
|
|
2229
|
+
label: "Refresh statistics if the cost is driven by a row overestimate",
|
|
2230
|
+
sql: "ANALYZE <table>;"
|
|
2231
|
+
}
|
|
2232
|
+
]
|
|
2233
|
+
},
|
|
2234
|
+
docsUrl: `${DOCS2}/runtime-config-query.html#GUC-JIT-ABOVE-COST`,
|
|
2235
|
+
meta: { jitMs: Math.round(jitTotal), jitPct: Math.round(jitPct) }
|
|
2236
|
+
})
|
|
2237
|
+
];
|
|
2238
|
+
}
|
|
2239
|
+
};
|
|
2240
|
+
|
|
2241
|
+
// src/advisor/rules/sort-spill-disk.ts
|
|
2242
|
+
var sortSpillDisk = {
|
|
2243
|
+
id: "PGX_SORT_SPILL_DISK",
|
|
2244
|
+
title: "Sort spilled to disk",
|
|
2245
|
+
defaultSeverity: "warn",
|
|
2246
|
+
requiresAnalyze: true,
|
|
2247
|
+
check(node, ctx) {
|
|
2248
|
+
if (node.nodeType !== "Sort") return [];
|
|
2249
|
+
const onDisk = node.sortSpaceType === "Disk" || node.sortMethod !== void 0 && /external/i.test(node.sortMethod);
|
|
2250
|
+
if (!onDisk) return [];
|
|
2251
|
+
const usedKiB = node.sortSpaceUsed ?? 0;
|
|
2252
|
+
const workMemRecommended = roundUpMiB(usedKiB > 0 ? usedKiB * 2.2 : 0);
|
|
2253
|
+
const usedText = usedKiB > 0 ? ` using ${fmtKiB(usedKiB)} of temp space` : "";
|
|
2254
|
+
const method = node.sortMethod ? ` (${node.sortMethod})` : "";
|
|
2255
|
+
const orderBy = node.sortKey && node.sortKey.length > 0 ? node.sortKey.join(", ") : "<ORDER BY columns>";
|
|
2256
|
+
const summary = usedKiB > 0 ? `Raise work_mem for this query so the sort stays in memory, e.g. SET work_mem = '${workMemRecommended}' at session or role scope (do NOT raise it globally without accounting for max_connections \u2014 each connection can allocate work_mem several times over). Alternatively, add an index on (${orderBy}) so rows arrive pre-sorted and the Sort node disappears.` : `Raise work_mem for this query so the sort stays in memory, e.g. SET work_mem = '64MB' at session or role scope (do NOT raise it globally without accounting for max_connections \u2014 each connection can allocate work_mem several times over). Alternatively, add an index on (${orderBy}) so rows arrive pre-sorted and the Sort node disappears.`;
|
|
2257
|
+
return [
|
|
2258
|
+
makeFinding(sortSpillDisk, ctx, node, {
|
|
2259
|
+
title: `Sort spilled to disk${usedText}`,
|
|
2260
|
+
detail: `The Sort node ran an external merge sort on disk${method}${usedText}, because the data exceeded work_mem.`,
|
|
2261
|
+
cause: "work_mem was too small to hold the sort set, so Postgres wrote sorted runs to temporary files and merged them \u2014 adding temp-file I/O that an in-memory sort avoids.",
|
|
2262
|
+
remediation: {
|
|
2263
|
+
summary,
|
|
2264
|
+
steps: [
|
|
2265
|
+
"Set work_mem at session or role scope for this workload, not cluster-wide.",
|
|
2266
|
+
`Size it above the spilled footprint${usedKiB > 0 ? ` (~${fmtKiB(usedKiB)}); ${workMemRecommended} leaves headroom` : ""}.`,
|
|
2267
|
+
`Or add an index on (${orderBy}) so the sort is satisfied by an ordered scan and removed entirely.`
|
|
2268
|
+
],
|
|
2269
|
+
commands: [
|
|
2270
|
+
{
|
|
2271
|
+
label: "Raise work_mem for this session",
|
|
2272
|
+
sql: `SET work_mem = '${usedKiB > 0 ? workMemRecommended : "64MB"}';`
|
|
2273
|
+
},
|
|
2274
|
+
{
|
|
2275
|
+
label: "Or set it per role",
|
|
2276
|
+
sql: `ALTER ROLE <role> SET work_mem = '${usedKiB > 0 ? workMemRecommended : "64MB"}';`
|
|
2277
|
+
},
|
|
2278
|
+
{
|
|
2279
|
+
label: "Or add an index matching the sort key",
|
|
2280
|
+
sql: `CREATE INDEX ON <table> (${orderBy});`
|
|
2281
|
+
}
|
|
2282
|
+
]
|
|
2283
|
+
},
|
|
2284
|
+
docsUrl: `${DOCS2}/runtime-config-resource.html#GUC-WORK-MEM`,
|
|
2285
|
+
meta: { sortSpaceUsedKiB: Math.round(usedKiB), workMemRecommended }
|
|
2286
|
+
})
|
|
2287
|
+
];
|
|
2288
|
+
}
|
|
2289
|
+
};
|
|
2290
|
+
|
|
2291
|
+
// src/advisor/rules/trigger-time.ts
|
|
2292
|
+
var triggerTime = {
|
|
2293
|
+
id: "PGX_TRIGGER_TIME",
|
|
2294
|
+
title: "Triggers consume significant time",
|
|
2295
|
+
defaultSeverity: "info",
|
|
2296
|
+
requiresAnalyze: true,
|
|
2297
|
+
check(node, ctx) {
|
|
2298
|
+
if (node !== ctx.tree.root) return [];
|
|
2299
|
+
const triggers = ctx.tree.triggers;
|
|
2300
|
+
const execMs = executionMs(ctx.tree);
|
|
2301
|
+
const triggerTotal = triggers.reduce((s, t) => s + (t.time ?? 0), 0);
|
|
2302
|
+
if (!triggers.length || !execMs || triggerTotal <= 0) return [];
|
|
2303
|
+
const pct = 100 * triggerTotal / execMs;
|
|
2304
|
+
if (pct <= ctx.thresholds.triggerPct) return [];
|
|
2305
|
+
const worst = triggers.reduce((a, b) => (b.time ?? 0) > (a.time ?? 0) ? b : a);
|
|
2306
|
+
const worstLabel = worst.name ?? worst.constraintName ?? "a trigger";
|
|
2307
|
+
const onRel = worst.relation ? ` on ${worst.relation}` : "";
|
|
2308
|
+
return [
|
|
2309
|
+
makeFinding(triggerTime, ctx, node, {
|
|
2310
|
+
title: `Triggers consumed ${fmtMs(triggerTotal)} (${fmtPct(pct)} of execution)`,
|
|
2311
|
+
detail: `Trigger execution took ${fmtMs(triggerTotal)} of the ${fmtMs(
|
|
2312
|
+
execMs
|
|
2313
|
+
)} total \u2014 ${fmtPct(pct)} of the time is spent outside the plan tree (heaviest: "${worstLabel}"${onRel}).`,
|
|
2314
|
+
cause: "Time spent firing triggers (often foreign-key constraint checks or AFTER triggers) is not attributed to any plan node, so it is invisible in the node timings even though it dominates the statement.",
|
|
2315
|
+
remediation: {
|
|
2316
|
+
summary: `Index the foreign-key columns involved in the constraint checks so each row's lookup is cheap, and review the trigger function bodies for per-row inefficiency. For bulk loads, defer constraints (SET CONSTRAINTS ALL DEFERRED) or disable and replay triggers around the batch.`,
|
|
2317
|
+
steps: [
|
|
2318
|
+
"Confirm both the referencing and referenced FK columns are indexed \u2014 an FK check does a lookup on the referenced key for every row, and a missing index makes it a full scan per row.",
|
|
2319
|
+
"Inspect each trigger function body for per-row work that could be batched or removed.",
|
|
2320
|
+
"For bulk INSERT/UPDATE/COPY, defer FK constraints until commit, or temporarily disable user triggers and replay the work once after the batch."
|
|
2321
|
+
],
|
|
2322
|
+
commands: [
|
|
2323
|
+
{
|
|
2324
|
+
label: "Index the referencing FK column(s) so constraint checks are cheap",
|
|
2325
|
+
sql: `CREATE INDEX ON <referencing_table> (<fk_columns>);`
|
|
2326
|
+
},
|
|
2327
|
+
{
|
|
2328
|
+
label: "Defer FK constraint checks to commit for a bulk load",
|
|
2329
|
+
sql: `BEGIN;
|
|
2330
|
+
SET CONSTRAINTS ALL DEFERRED;
|
|
2331
|
+
-- bulk INSERT/UPDATE/COPY here
|
|
2332
|
+
COMMIT;`
|
|
2333
|
+
},
|
|
2334
|
+
{
|
|
2335
|
+
label: "Disable user triggers around a batch, then re-enable",
|
|
2336
|
+
sql: `ALTER TABLE <table> DISABLE TRIGGER USER;
|
|
2337
|
+
-- bulk work here
|
|
2338
|
+
ALTER TABLE <table> ENABLE TRIGGER USER;`
|
|
2339
|
+
}
|
|
2340
|
+
]
|
|
2341
|
+
},
|
|
2342
|
+
docsUrl: `${DOCS2}/sql-createtrigger.html`,
|
|
2343
|
+
meta: { triggerMs: Math.round(triggerTotal), triggerPct: Math.round(pct) }
|
|
2344
|
+
})
|
|
2345
|
+
];
|
|
2346
|
+
}
|
|
2347
|
+
};
|
|
2348
|
+
|
|
2349
|
+
// src/advisor/rules/workers-not-launched.ts
|
|
2350
|
+
var workersNotLaunched = {
|
|
2351
|
+
id: "PGX_WORKERS_NOT_LAUNCHED",
|
|
2352
|
+
title: "Parallel workers planned but not launched",
|
|
2353
|
+
defaultSeverity: "info",
|
|
2354
|
+
requiresAnalyze: true,
|
|
2355
|
+
check(node, ctx) {
|
|
2356
|
+
if (node.nodeType !== "Gather" && node.nodeType !== "Gather Merge") return [];
|
|
2357
|
+
if (node.workersPlanned === void 0 || node.workersLaunched === void 0) return [];
|
|
2358
|
+
if (node.workersLaunched >= node.workersPlanned) return [];
|
|
2359
|
+
const planned = node.workersPlanned;
|
|
2360
|
+
const launched = node.workersLaunched;
|
|
2361
|
+
const shortfall = planned - launched;
|
|
2362
|
+
return [
|
|
2363
|
+
makeFinding(workersNotLaunched, ctx, node, {
|
|
2364
|
+
title: `${node.nodeType} got ${fmtInt(launched)} of ${fmtInt(planned)} planned workers`,
|
|
2365
|
+
detail: `This ${node.nodeType} planned for ${fmtInt(planned)} parallel worker${planned === 1 ? "" : "s"} but only ${fmtInt(launched)} were launched (${fmtInt(shortfall)} short), so part of the work ran serially.`,
|
|
2366
|
+
cause: "The global background-worker pool was exhausted: max_worker_processes or max_parallel_workers was already saturated (often by other concurrent parallel queries) when this node tried to start its workers.",
|
|
2367
|
+
remediation: {
|
|
2368
|
+
summary: `Raise max_parallel_workers and max_worker_processes so the pool can supply the ${fmtInt(
|
|
2369
|
+
planned
|
|
2370
|
+
)} workers this query plans for, and confirm max_parallel_workers_per_gather permits them. If parallelism is not actually speeding this query up, the shortfall is harmless.`,
|
|
2371
|
+
steps: [
|
|
2372
|
+
"Check current limits: max_worker_processes, max_parallel_workers, max_parallel_workers_per_gather.",
|
|
2373
|
+
"max_parallel_workers must be <= max_worker_processes; raise both together (max_worker_processes change needs a restart).",
|
|
2374
|
+
"Look for other concurrent parallel queries saturating the shared pool during peak load.",
|
|
2375
|
+
"If a serial plan is no slower here, leave the settings alone \u2014 this is informational."
|
|
2376
|
+
],
|
|
2377
|
+
commands: [
|
|
2378
|
+
{
|
|
2379
|
+
label: "Enlarge the global parallel-worker pool",
|
|
2380
|
+
sql: "ALTER SYSTEM SET max_parallel_workers = '<N>';\nALTER SYSTEM SET max_worker_processes = '<N+>';\nSELECT pg_reload_conf();"
|
|
2381
|
+
},
|
|
2382
|
+
{
|
|
2383
|
+
label: "Allow more workers per Gather",
|
|
2384
|
+
sql: "ALTER SYSTEM SET max_parallel_workers_per_gather = '<N>';\nSELECT pg_reload_conf();"
|
|
2385
|
+
},
|
|
2386
|
+
{
|
|
2387
|
+
label: "Inspect the current settings",
|
|
2388
|
+
sql: "SELECT name, setting FROM pg_settings WHERE name IN ('max_worker_processes', 'max_parallel_workers', 'max_parallel_workers_per_gather');"
|
|
2389
|
+
}
|
|
2390
|
+
]
|
|
2391
|
+
},
|
|
2392
|
+
docsUrl: `${DOCS2}/runtime-config-resource.html#GUC-MAX-PARALLEL-WORKERS`,
|
|
2393
|
+
meta: { planned, launched }
|
|
2394
|
+
})
|
|
2395
|
+
];
|
|
2396
|
+
}
|
|
2397
|
+
};
|
|
2398
|
+
|
|
2399
|
+
// src/advisor/rules/index.ts
|
|
2400
|
+
var ALL_RULES = [
|
|
2401
|
+
cartesianProduct,
|
|
2402
|
+
seqScanLarge,
|
|
2403
|
+
nestedLoopLargeOuter,
|
|
2404
|
+
highFilterDiscard,
|
|
2405
|
+
limitLargeOffset,
|
|
2406
|
+
sortSpillDisk,
|
|
2407
|
+
hashSpillDisk,
|
|
2408
|
+
memoizeEvictions,
|
|
2409
|
+
correlatedSubplan,
|
|
2410
|
+
rowMisestimate,
|
|
2411
|
+
filterCouldBeIndexCond,
|
|
2412
|
+
couldBeIndexOnly,
|
|
2413
|
+
indexOnlyHeapFetches,
|
|
2414
|
+
bitmapLossy,
|
|
2415
|
+
workersNotLaunched,
|
|
2416
|
+
lowCacheHit,
|
|
2417
|
+
significantJit,
|
|
2418
|
+
triggerTime
|
|
2419
|
+
];
|
|
2420
|
+
|
|
2421
|
+
// src/advisor/index.ts
|
|
2422
|
+
function runAdvisor(tree, config = DEFAULT_CONFIG) {
|
|
2423
|
+
const ctx = {
|
|
2424
|
+
tree,
|
|
2425
|
+
thresholds: config.thresholds,
|
|
2426
|
+
severityOf: (id, fallback) => config.rules[id]?.severity ?? fallback,
|
|
2427
|
+
isEnabled: (id) => config.rules[id]?.enabled !== false
|
|
2428
|
+
};
|
|
2429
|
+
const nodes = flatten(tree.root);
|
|
2430
|
+
const diagnostics = [];
|
|
2431
|
+
for (const rule of ALL_RULES) {
|
|
2432
|
+
if (!ctx.isEnabled(rule.id)) continue;
|
|
2433
|
+
if (rule.requiresAnalyze && !tree.hasAnalyze) continue;
|
|
2434
|
+
if (rule.requiresBuffers && !tree.hasBuffers) continue;
|
|
2435
|
+
for (const node of nodes) {
|
|
2436
|
+
for (const finding2 of rule.check(node, ctx)) diagnostics.push(finding2);
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
diagnostics.sort(bySeverity);
|
|
2440
|
+
let worst = null;
|
|
2441
|
+
for (const d of diagnostics) worst = worst === null ? d.severity : maxSeverity(worst, d.severity);
|
|
2442
|
+
const bn = bottlenecks(tree, 5);
|
|
2443
|
+
return {
|
|
2444
|
+
tree,
|
|
2445
|
+
diagnostics,
|
|
2446
|
+
bottlenecks: bn,
|
|
2447
|
+
verdict: buildVerdict(tree, diagnostics, bn),
|
|
2448
|
+
worstSeverity: worst
|
|
2449
|
+
};
|
|
2450
|
+
}
|
|
2451
|
+
function buildVerdict(tree, diagnostics, bn) {
|
|
2452
|
+
const counts = { error: 0, warn: 0, info: 0 };
|
|
2453
|
+
for (const d of diagnostics) counts[d.severity]++;
|
|
2454
|
+
const parts = [];
|
|
2455
|
+
if (counts.error) parts.push(`${counts.error} critical`);
|
|
2456
|
+
if (counts.warn) parts.push(`${counts.warn} warning${counts.warn > 1 ? "s" : ""}`);
|
|
2457
|
+
if (counts.info) parts.push(`${counts.info} note${counts.info > 1 ? "s" : ""}`);
|
|
2458
|
+
const findings = parts.length ? parts.join(", ") : "no issues found";
|
|
2459
|
+
const top = bn[0];
|
|
2460
|
+
let bottleneck = "";
|
|
2461
|
+
if (top?.metrics.selfMs !== void 0) {
|
|
2462
|
+
const pct = top.metrics.pctOfTotal !== void 0 ? ` (${top.metrics.pctOfTotal.toFixed(0)}% of time)` : "";
|
|
2463
|
+
bottleneck = ` \u2014 top cost: ${nodeLabel(top)}${pct}`;
|
|
2464
|
+
}
|
|
2465
|
+
const ms = executionMs(tree);
|
|
2466
|
+
const timing = ms !== void 0 ? ` Total ${fmtMs(ms)}.` : " Cost-only plan (no timing).";
|
|
2467
|
+
return `${findings}${bottleneck}.${timing}`;
|
|
2468
|
+
}
|
|
2469
|
+
|
|
2470
|
+
// src/input/redact.ts
|
|
2471
|
+
function redactExpression(expr) {
|
|
2472
|
+
return expr.replace(/'(?:[^']|'')*'/g, "'?'").replace(/\b\d+(?:\.\d+)?\b/g, "N");
|
|
2473
|
+
}
|
|
2474
|
+
var EXPR_FIELDS = [
|
|
2475
|
+
"filter",
|
|
2476
|
+
"indexCond",
|
|
2477
|
+
"recheckCond",
|
|
2478
|
+
"hashCond",
|
|
2479
|
+
"joinFilter"
|
|
2480
|
+
];
|
|
2481
|
+
function redactNode(node) {
|
|
2482
|
+
for (const field of EXPR_FIELDS) {
|
|
2483
|
+
const value = node[field];
|
|
2484
|
+
if (typeof value === "string") node[field] = redactExpression(value);
|
|
2485
|
+
}
|
|
2486
|
+
if (node.output) node.output = node.output.map(redactExpression);
|
|
2487
|
+
if (node.sortKey) node.sortKey = node.sortKey.map(redactExpression);
|
|
2488
|
+
}
|
|
2489
|
+
function redactPlanTree(tree) {
|
|
2490
|
+
walk(tree.root, redactNode);
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
// src/locks/advisor.ts
|
|
2494
|
+
var DOCS3 = "https://www.postgresql.org/docs/current";
|
|
2495
|
+
function analyzeLocks(sql, tree) {
|
|
2496
|
+
const code = stripSql(sql);
|
|
2497
|
+
const upper = code.toUpperCase();
|
|
2498
|
+
const kw = (code.trim().split(/\s+/)[0] ?? "").toUpperCase();
|
|
2499
|
+
const out = [];
|
|
2500
|
+
const add = (id, severity, parts) => {
|
|
2501
|
+
out.push({
|
|
2502
|
+
code: id,
|
|
2503
|
+
domain: "plan",
|
|
2504
|
+
severity,
|
|
2505
|
+
title: parts.title,
|
|
2506
|
+
detail: parts.detail,
|
|
2507
|
+
cause: parts.cause,
|
|
2508
|
+
remediation: { summary: parts.fix, commands: parts.commands },
|
|
2509
|
+
docsUrl: `${DOCS3}/explicit-locking.html`
|
|
2510
|
+
});
|
|
2511
|
+
};
|
|
2512
|
+
if (/\bVACUUM\s+FULL\b/.test(upper) || /\bCLUSTER\b/.test(upper) || /\bALTER\s+TABLE\b[\s\S]*\b(TYPE|SET\s+DATA\s+TYPE)\b/.test(upper)) {
|
|
2513
|
+
add("PGX_LOCK_TABLE_REWRITE", "error", {
|
|
2514
|
+
title: "Operation rewrites the table under an ACCESS EXCLUSIVE lock",
|
|
2515
|
+
detail: "VACUUM FULL / CLUSTER / a column-type change rewrites the whole table and holds ACCESS EXCLUSIVE for the duration.",
|
|
2516
|
+
cause: "ACCESS EXCLUSIVE blocks every reader and writer until the rewrite finishes \u2014 an outage on a busy table.",
|
|
2517
|
+
fix: "Avoid the full rewrite: use pg_repack for bloat instead of VACUUM FULL/CLUSTER; for type changes, add a new column, backfill in batches, and swap. Always do rewrites off-peak with a lock_timeout.",
|
|
2518
|
+
commands: [{ label: "Bound the wait", sql: "SET lock_timeout = '3s';" }]
|
|
2519
|
+
});
|
|
2520
|
+
}
|
|
2521
|
+
if (/\bCREATE\s+(UNIQUE\s+)?INDEX\b/.test(upper) && !/\bCONCURRENTLY\b/.test(upper)) {
|
|
2522
|
+
add("PGX_DDL_NO_CONCURRENTLY", "warn", {
|
|
2523
|
+
title: "CREATE INDEX without CONCURRENTLY blocks writes",
|
|
2524
|
+
detail: "A plain CREATE INDEX takes a SHARE lock, blocking all writes to the table until the build completes.",
|
|
2525
|
+
cause: "On a large or busy table the build can take minutes, during which inserts/updates/deletes are blocked.",
|
|
2526
|
+
fix: "Build the index online with CONCURRENTLY (note: it cannot run inside a transaction and may leave an INVALID index on failure, which you then drop and recreate).",
|
|
2527
|
+
commands: [{ label: "Build online", sql: "CREATE INDEX CONCURRENTLY ON <table> (<cols>);" }]
|
|
2528
|
+
});
|
|
2529
|
+
}
|
|
2530
|
+
if (/\bDROP\s+INDEX\b/.test(upper) && !/\bCONCURRENTLY\b/.test(upper)) {
|
|
2531
|
+
add("PGX_DROP_INDEX_NO_CONCURRENTLY", "warn", {
|
|
2532
|
+
title: "DROP INDEX without CONCURRENTLY takes ACCESS EXCLUSIVE",
|
|
2533
|
+
detail: "A plain DROP INDEX locks the table with ACCESS EXCLUSIVE.",
|
|
2534
|
+
cause: "Readers and writers block until the drop completes.",
|
|
2535
|
+
fix: "Use DROP INDEX CONCURRENTLY to avoid blocking.",
|
|
2536
|
+
commands: [{ label: "Drop online", sql: "DROP INDEX CONCURRENTLY <index>;" }]
|
|
2537
|
+
});
|
|
2538
|
+
}
|
|
2539
|
+
if (/\bTRUNCATE\b/.test(upper)) {
|
|
2540
|
+
add("PGX_LOCK_TRUNCATE", "info", {
|
|
2541
|
+
title: "TRUNCATE takes an ACCESS EXCLUSIVE lock",
|
|
2542
|
+
detail: "TRUNCATE briefly locks the table with ACCESS EXCLUSIVE.",
|
|
2543
|
+
cause: "It is fast (no row scan) but still blocks all access while it runs and is transactional.",
|
|
2544
|
+
fix: "Fine for maintenance windows; on a hot table, set a lock_timeout so it fails fast instead of queueing behind/ahead of other transactions.",
|
|
2545
|
+
commands: [{ label: "Bound the wait", sql: "SET lock_timeout = '3s';" }]
|
|
2546
|
+
});
|
|
2547
|
+
}
|
|
2548
|
+
if (/\bLOCK\s+TABLE\b/.test(upper)) {
|
|
2549
|
+
add("PGX_LOCK_TABLE_EXPLICIT", "info", {
|
|
2550
|
+
title: "Explicit LOCK TABLE",
|
|
2551
|
+
detail: "An explicit LOCK TABLE acquires the named lock mode for the rest of the transaction.",
|
|
2552
|
+
cause: "Holding a strong lock longer than necessary blocks other sessions.",
|
|
2553
|
+
fix: "Use the lowest lock mode that suffices and keep the transaction short."
|
|
2554
|
+
});
|
|
2555
|
+
}
|
|
2556
|
+
if (/\bFOR\s+(UPDATE|SHARE|NO\s+KEY\s+UPDATE|KEY\s+SHARE)\b/.test(upper) && !/\bLIMIT\b/.test(upper)) {
|
|
2557
|
+
add("PGX_SELECT_FOR_UPDATE_UNBOUNDED", "warn", {
|
|
2558
|
+
title: "Row-locking SELECT without a LIMIT",
|
|
2559
|
+
detail: "SELECT \u2026 FOR UPDATE/SHARE locks every row it matches, held until the transaction ends.",
|
|
2560
|
+
cause: "Locking an unbounded set increases contention and deadlock risk with concurrent updaters.",
|
|
2561
|
+
fix: "Bound the set with a deterministic ORDER BY + LIMIT (and process in batches); a consistent lock order also avoids deadlocks.",
|
|
2562
|
+
commands: [{ label: "Bound + order", sql: "SELECT \u2026 ORDER BY id FOR UPDATE LIMIT 100;" }]
|
|
2563
|
+
});
|
|
2564
|
+
}
|
|
2565
|
+
if (kw === "UPDATE" || kw === "DELETE") {
|
|
2566
|
+
if (!/\bWHERE\b/.test(upper)) {
|
|
2567
|
+
add("PGX_WRITE_NO_WHERE", "warn", {
|
|
2568
|
+
title: `${kw} without a WHERE clause locks every row`,
|
|
2569
|
+
detail: `This ${kw} touches the whole table, taking a row lock on every row until commit.`,
|
|
2570
|
+
cause: "All rows are locked for the transaction's duration, blocking concurrent writers and bloating the table.",
|
|
2571
|
+
fix: "Add a WHERE clause; for large rewrites, update in batches (e.g. by primary-key ranges) and commit between batches."
|
|
2572
|
+
});
|
|
2573
|
+
} else if (tree && hasSeqScanOnTarget(tree, targetTable(code, kw))) {
|
|
2574
|
+
const rel = targetTable(code, kw);
|
|
2575
|
+
add("PGX_UPDATE_UNINDEXED_PREDICATE", "warn", {
|
|
2576
|
+
title: `${kw} scans ${rel ?? "the table"} sequentially to find rows`,
|
|
2577
|
+
detail: `The plan uses a Seq Scan on ${rel ?? "the target table"}, so the ${kw} reads (and locks the touched rows of) the whole table.`,
|
|
2578
|
+
cause: "An unindexed predicate means more rows scanned and locked, and the locks are held until commit.",
|
|
2579
|
+
fix: `Index the ${kw}'s WHERE columns so it finds rows via an index and locks only what it changes.`,
|
|
2580
|
+
commands: [
|
|
2581
|
+
{
|
|
2582
|
+
label: "Index the predicate",
|
|
2583
|
+
sql: `CREATE INDEX ON ${rel ?? "<table>"} (<where columns>);`
|
|
2584
|
+
}
|
|
2585
|
+
]
|
|
2586
|
+
});
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
if (/^(ALTER|CREATE|DROP)\b/.test(kw) && !/\bCONCURRENTLY\b/.test(upper) && !/\bSET\s+LOCK_TIMEOUT\b/.test(upper)) {
|
|
2590
|
+
add("PGX_DDL_NO_LOCK_TIMEOUT", "warn", {
|
|
2591
|
+
title: "DDL without a lock_timeout can stall the whole table",
|
|
2592
|
+
detail: "This DDL needs a strong lock; if it waits behind a long transaction, every query that arrives after it also queues behind the DDL.",
|
|
2593
|
+
cause: "A blocked ACCESS EXCLUSIVE request sits at the head of the lock queue and blocks new readers/writers too.",
|
|
2594
|
+
fix: "Set a short lock_timeout before the DDL and retry, so it fails fast instead of forming a queue.",
|
|
2595
|
+
commands: [
|
|
2596
|
+
{
|
|
2597
|
+
label: "Fail fast, then retry",
|
|
2598
|
+
sql: "SET lock_timeout = '3s';\n-- run the DDL; on timeout, retry later"
|
|
2599
|
+
}
|
|
2600
|
+
]
|
|
2601
|
+
});
|
|
2602
|
+
}
|
|
2603
|
+
return out;
|
|
2604
|
+
}
|
|
2605
|
+
function stripSql(sql) {
|
|
2606
|
+
return sql.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/--[^\n]*/g, " ").replace(/'(?:[^']|'')*'/g, "''").replace(/"(?:[^"]|"")*"/g, '"x"');
|
|
2607
|
+
}
|
|
2608
|
+
function targetTable(code, kw) {
|
|
2609
|
+
const re = kw === "DELETE" ? /\bDELETE\s+FROM\s+([A-Za-z_][\w.]*)/i : /\bUPDATE\s+(?:ONLY\s+)?([A-Za-z_][\w.]*)/i;
|
|
2610
|
+
const m = re.exec(code);
|
|
2611
|
+
return m?.[1];
|
|
2612
|
+
}
|
|
2613
|
+
function hasSeqScanOnTarget(tree, table) {
|
|
2614
|
+
let found = false;
|
|
2615
|
+
walk(tree.root, (n) => {
|
|
2616
|
+
if (n.nodeType === "Seq Scan" && (!table || n.relationName === bareName(table))) found = true;
|
|
2617
|
+
});
|
|
2618
|
+
return found;
|
|
2619
|
+
}
|
|
2620
|
+
function bareName(qualified) {
|
|
2621
|
+
const parts = qualified.split(".");
|
|
2622
|
+
return parts[parts.length - 1] ?? qualified;
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
// src/report/tree.ts
|
|
2626
|
+
function treeLines(tree, glyphs) {
|
|
2627
|
+
const lines = [];
|
|
2628
|
+
const recurse = (node, indent, isLast, isRoot) => {
|
|
2629
|
+
const connector = isRoot ? "" : isLast ? glyphs.last : glyphs.branch;
|
|
2630
|
+
lines.push({ node, prefix: indent + connector });
|
|
2631
|
+
const childIndent = isRoot ? "" : indent + (isLast ? glyphs.space : glyphs.vert);
|
|
2632
|
+
node.children.forEach((child, i) => {
|
|
2633
|
+
recurse(child, childIndent, i === node.children.length - 1, false);
|
|
2634
|
+
});
|
|
2635
|
+
};
|
|
2636
|
+
recurse(tree.root, "", true, true);
|
|
2637
|
+
return lines;
|
|
2638
|
+
}
|
|
2639
|
+
function nodeSummary(node) {
|
|
2640
|
+
const m = node.metrics;
|
|
2641
|
+
const parts = [];
|
|
2642
|
+
if (m.totalRows !== void 0) {
|
|
2643
|
+
let rows = `rows=${fmtInt(m.totalRows)}`;
|
|
2644
|
+
if (m.estimateFactor !== void 0 && m.estimateFactor >= 2 && m.estimateDirection !== "accurate") {
|
|
2645
|
+
rows += ` (est ${fmtInt(node.planRows)}, ${m.estimateFactor.toFixed(0)}\xD7 ${m.estimateDirection})`;
|
|
2646
|
+
}
|
|
2647
|
+
parts.push(rows);
|
|
2648
|
+
} else {
|
|
2649
|
+
parts.push(`rows\u2248${fmtInt(node.planRows)} est`);
|
|
2650
|
+
}
|
|
2651
|
+
if (m.selfMs !== void 0) {
|
|
2652
|
+
let t = `self ${fmtMs(m.selfMs)}`;
|
|
2653
|
+
if (m.pctOfTotal !== void 0 && m.pctOfTotal >= 1) t += ` (${m.pctOfTotal.toFixed(0)}%)`;
|
|
2654
|
+
parts.push(t);
|
|
2655
|
+
}
|
|
2656
|
+
if (node.metrics.cacheHitRatio != null) {
|
|
2657
|
+
parts.push(`cache ${(node.metrics.cacheHitRatio * 100).toFixed(0)}%`);
|
|
2658
|
+
}
|
|
2659
|
+
return parts.join(" \xB7 ");
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
// src/report/json.ts
|
|
2663
|
+
var JSON_SCHEMA_VERSION = 1;
|
|
2664
|
+
function renderJson(result, pretty = true) {
|
|
2665
|
+
return JSON.stringify(buildReport(result), null, pretty ? 2 : 0);
|
|
2666
|
+
}
|
|
2667
|
+
function buildReport(result) {
|
|
2668
|
+
const { tree, diagnostics, bottlenecks: bottlenecks2 } = result;
|
|
2669
|
+
const counts = { error: 0, warn: 0, info: 0 };
|
|
2670
|
+
for (const d of diagnostics) counts[d.severity]++;
|
|
2671
|
+
return {
|
|
2672
|
+
schemaVersion: JSON_SCHEMA_VERSION,
|
|
2673
|
+
verdict: result.verdict,
|
|
2674
|
+
worstSeverity: result.worstSeverity,
|
|
2675
|
+
summary: {
|
|
2676
|
+
planningTimeMs: tree.planningTime ?? null,
|
|
2677
|
+
executionTimeMs: executionMs(tree) ?? null,
|
|
2678
|
+
serializationTimeMs: tree.serializationTime ?? null,
|
|
2679
|
+
hasAnalyze: tree.hasAnalyze,
|
|
2680
|
+
hasBuffers: tree.hasBuffers,
|
|
2681
|
+
nodeCount: flatten(tree.root).length,
|
|
2682
|
+
findings: counts
|
|
2683
|
+
},
|
|
2684
|
+
triggers: tree.triggers,
|
|
2685
|
+
jit: tree.jit ?? null,
|
|
2686
|
+
settings: tree.settings ?? null,
|
|
2687
|
+
diagnostics,
|
|
2688
|
+
bottlenecks: bottlenecks2.filter((n) => (n.metrics.selfMs ?? 0) > 0).map((n) => ({
|
|
2689
|
+
id: n.id,
|
|
2690
|
+
label: nodeLabel(n),
|
|
2691
|
+
nodeType: n.nodeType,
|
|
2692
|
+
relation: n.relationName ?? null,
|
|
2693
|
+
selfMs: n.metrics.selfMs ?? null,
|
|
2694
|
+
pctOfTotal: n.metrics.pctOfTotal ?? null,
|
|
2695
|
+
totalRows: n.metrics.totalRows ?? null
|
|
2696
|
+
})),
|
|
2697
|
+
stats: aggregateStats(tree),
|
|
2698
|
+
plan: serializeNode(tree.root)
|
|
2699
|
+
};
|
|
2700
|
+
}
|
|
2701
|
+
function serializeNode(node) {
|
|
2702
|
+
const { children, metrics, raw, ...fields } = node;
|
|
2703
|
+
return { ...fields, metrics, children: children.map(serializeNode) };
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
// src/report/html.ts
|
|
2707
|
+
var SEV = {
|
|
2708
|
+
error: { label: "Critical", cls: "sev-error" },
|
|
2709
|
+
warn: { label: "Warning", cls: "sev-warn" },
|
|
2710
|
+
info: { label: "Note", cls: "sev-info" }
|
|
2711
|
+
};
|
|
2712
|
+
function esc(s) {
|
|
2713
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
2714
|
+
}
|
|
2715
|
+
function renderHtml(result) {
|
|
2716
|
+
const { tree, diagnostics } = result;
|
|
2717
|
+
const ms = executionMs(tree);
|
|
2718
|
+
const treeHtml = treeLines(tree, UNICODE_TREE).map(({ node, prefix }) => {
|
|
2719
|
+
const pct = node.metrics.pctOfTotal ?? 0;
|
|
2720
|
+
const heat2 = pct >= 50 ? "hot" : pct >= 20 ? "warm" : pct >= 5 ? "" : "cold";
|
|
2721
|
+
return `<div class="node ${heat2}"><span class="glyph">${esc(prefix)}</span><span class="label">${esc(nodeLabel(node))}</span> <span class="meta">${esc(nodeSummary(node))}</span></div>`;
|
|
2722
|
+
}).join("\n");
|
|
2723
|
+
const findingsHtml = diagnostics.length ? diagnostics.map(findingHtml).join("\n") : '<p class="ok">No anti-patterns detected. \u{1F389}</p>';
|
|
2724
|
+
return `<!doctype html>
|
|
2725
|
+
<html lang="en">
|
|
2726
|
+
<head>
|
|
2727
|
+
<meta charset="utf-8">
|
|
2728
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2729
|
+
<title>pg-explain report</title>
|
|
2730
|
+
<style>
|
|
2731
|
+
:root { color-scheme: light dark; }
|
|
2732
|
+
body { font: 15px/1.5 -apple-system, system-ui, sans-serif; margin: 0; padding: 2rem; max-width: 980px; margin-inline: auto; }
|
|
2733
|
+
h1 { font-size: 1.4rem; } h2 { font-size: 1.1rem; margin-top: 2rem; border-bottom: 1px solid #8884; padding-bottom: .3rem; }
|
|
2734
|
+
.verdict { padding: .75rem 1rem; border-left: 4px solid #888; background: #8881; border-radius: 4px; }
|
|
2735
|
+
.verdict.sev-error { border-color: #e5484d; } .verdict.sev-warn { border-color: #f5a623; } .verdict.sev-info { border-color: #4493f8; }
|
|
2736
|
+
table { border-collapse: collapse; width: 100%; } td, th { text-align: left; padding: .3rem .6rem; border-bottom: 1px solid #8883; }
|
|
2737
|
+
.tree { overflow-x: auto; } .node { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre; font-size: 13px; }
|
|
2738
|
+
.glyph { color: #8888; } .meta { color: #888; }
|
|
2739
|
+
.node.hot .label { color: #e5484d; font-weight: 700; } .node.warm .label { color: #f5a623; } .node.cold .label { opacity: .6; }
|
|
2740
|
+
.finding { border: 1px solid #8883; border-radius: 6px; padding: 1rem; margin: 1rem 0; }
|
|
2741
|
+
.finding .tag { font-size: .75rem; font-weight: 700; padding: .1rem .5rem; border-radius: 3px; color: #fff; }
|
|
2742
|
+
.sev-error .tag { background: #e5484d; } .sev-warn .tag { background: #f5a623; } .sev-info .tag { background: #4493f8; }
|
|
2743
|
+
.finding code, pre { font-family: ui-monospace, monospace; font-size: 13px; }
|
|
2744
|
+
pre { background: #8881; padding: .6rem .8rem; border-radius: 4px; overflow-x: auto; }
|
|
2745
|
+
.label-cmd { color: #888; font-size: .85rem; margin-top: .5rem; }
|
|
2746
|
+
.ok { color: #2e7d32; }
|
|
2747
|
+
</style>
|
|
2748
|
+
</head>
|
|
2749
|
+
<body>
|
|
2750
|
+
<h1>pg-explain report</h1>
|
|
2751
|
+
<div class="verdict ${result.worstSeverity ? SEV[result.worstSeverity].cls : ""}">${esc(result.verdict)}</div>
|
|
2752
|
+
|
|
2753
|
+
<h2>Summary</h2>
|
|
2754
|
+
<table>
|
|
2755
|
+
${tree.planningTime !== void 0 ? `<tr><th>Planning time</th><td>${esc(fmtMs(tree.planningTime))}</td></tr>` : ""}
|
|
2756
|
+
${ms !== void 0 ? `<tr><th>Execution time</th><td>${esc(fmtMs(ms))}</td></tr>` : ""}
|
|
2757
|
+
${!tree.hasAnalyze ? "<tr><th>Mode</th><td>cost-only (no ANALYZE)</td></tr>" : ""}
|
|
2758
|
+
<tr><th>Findings</th><td>${diagnostics.length}</td></tr>
|
|
2759
|
+
</table>
|
|
2760
|
+
|
|
2761
|
+
<h2>Plan tree</h2>
|
|
2762
|
+
<div class="tree">
|
|
2763
|
+
${treeHtml}
|
|
2764
|
+
</div>
|
|
2765
|
+
|
|
2766
|
+
<h2>Findings</h2>
|
|
2767
|
+
${findingsHtml}
|
|
2768
|
+
</body>
|
|
2769
|
+
</html>
|
|
2770
|
+
`;
|
|
2771
|
+
}
|
|
2772
|
+
function findingHtml(d) {
|
|
2773
|
+
const sev = SEV[d.severity];
|
|
2774
|
+
const steps = d.remediation.steps?.length ? `<ul>${d.remediation.steps.map((s) => `<li>${esc(s)}</li>`).join("")}</ul>` : "";
|
|
2775
|
+
const cmds = (d.remediation.commands ?? []).map((c) => {
|
|
2776
|
+
const body = c.sql ?? c.shell ?? "";
|
|
2777
|
+
const label = c.label ? `<div class="label-cmd">${esc(c.label)}</div>` : "";
|
|
2778
|
+
return `${label}<pre><code>${esc(body)}</code></pre>`;
|
|
2779
|
+
}).join("");
|
|
2780
|
+
const docs = d.docsUrl ? `<p>\u{1F4D6} <a href="${esc(d.docsUrl)}">PostgreSQL docs</a></p>` : "";
|
|
2781
|
+
const meta = d.location?.relation ? ` <span class="meta">on ${esc(d.location.relation)}</span>` : "";
|
|
2782
|
+
return `<div class="finding ${sev.cls}">
|
|
2783
|
+
<p><span class="tag">${sev.label}</span> <strong>${esc(d.title)}</strong> <code>${esc(d.code)}</code>${meta}</p>
|
|
2784
|
+
<p><strong>What:</strong> ${esc(d.detail)}</p>
|
|
2785
|
+
<p><strong>Why:</strong> ${esc(d.cause)}</p>
|
|
2786
|
+
<p><strong>Fix:</strong> ${esc(d.remediation.summary)}</p>
|
|
2787
|
+
${steps}
|
|
2788
|
+
${cmds}
|
|
2789
|
+
${docs}
|
|
2790
|
+
</div>`;
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
// src/report/markdown.ts
|
|
2794
|
+
var SEV_LABEL = {
|
|
2795
|
+
error: "\u{1F534} Critical",
|
|
2796
|
+
warn: "\u{1F7E0} Warning",
|
|
2797
|
+
info: "\u{1F535} Note"
|
|
2798
|
+
};
|
|
2799
|
+
function renderMarkdown(result, opts = {}) {
|
|
2800
|
+
const { tree, diagnostics, bottlenecks: bottlenecks2 } = result;
|
|
2801
|
+
const out = [];
|
|
2802
|
+
out.push("# pg-explain report", "");
|
|
2803
|
+
out.push(`> **Verdict:** ${result.verdict}`, "");
|
|
2804
|
+
out.push("## Summary", "");
|
|
2805
|
+
const ms = executionMs(tree);
|
|
2806
|
+
out.push("| Metric | Value |", "| --- | --- |");
|
|
2807
|
+
if (tree.planningTime !== void 0) out.push(`| Planning time | ${fmtMs(tree.planningTime)} |`);
|
|
2808
|
+
if (ms !== void 0) out.push(`| Execution time | ${fmtMs(ms)} |`);
|
|
2809
|
+
if (!tree.hasAnalyze) out.push("| Mode | cost-only (no ANALYZE) |");
|
|
2810
|
+
out.push(`| Findings | ${summarizeCounts(diagnostics)} |`, "");
|
|
2811
|
+
if (opts.tldr) {
|
|
2812
|
+
out.push(...renderFindings(diagnostics, true));
|
|
2813
|
+
return `${out.join("\n").trimEnd()}
|
|
2814
|
+
`;
|
|
2815
|
+
}
|
|
2816
|
+
out.push("## Plan tree", "", "```");
|
|
2817
|
+
for (const { node, prefix } of treeLines(tree, UNICODE_TREE)) {
|
|
2818
|
+
out.push(`${prefix}${nodeLabel(node)} \u2014 ${nodeSummary(node)}`);
|
|
2819
|
+
}
|
|
2820
|
+
out.push("```", "");
|
|
2821
|
+
const ranked = bottlenecks2.filter((n) => (n.metrics.selfMs ?? 0) > 0);
|
|
2822
|
+
if (ranked.length) {
|
|
2823
|
+
out.push("## Bottlenecks (by self time)", "");
|
|
2824
|
+
out.push("| # | Node | Self time | % of total | Rows |", "| --- | --- | --- | --- | --- |");
|
|
2825
|
+
ranked.forEach((node, i) => {
|
|
2826
|
+
const pct = node.metrics.pctOfTotal !== void 0 ? `${node.metrics.pctOfTotal.toFixed(1)}%` : "\u2014";
|
|
2827
|
+
const rows = node.metrics.totalRows !== void 0 ? fmtInt(node.metrics.totalRows) : "\u2014";
|
|
2828
|
+
out.push(
|
|
2829
|
+
`| ${i + 1} | ${nodeLabel(node)} | ${fmtMs(node.metrics.selfMs ?? 0)} | ${pct} | ${rows} |`
|
|
2830
|
+
);
|
|
2831
|
+
});
|
|
2832
|
+
out.push("");
|
|
2833
|
+
}
|
|
2834
|
+
out.push(...renderFindings(diagnostics, false));
|
|
2835
|
+
return `${out.join("\n").trimEnd()}
|
|
2836
|
+
`;
|
|
2837
|
+
}
|
|
2838
|
+
function renderFindings(diagnostics, tldr) {
|
|
2839
|
+
const out = ["## Findings", ""];
|
|
2840
|
+
if (diagnostics.length === 0) {
|
|
2841
|
+
out.push("No anti-patterns detected. \u{1F389}", "");
|
|
2842
|
+
return out;
|
|
2843
|
+
}
|
|
2844
|
+
for (const d of diagnostics) {
|
|
2845
|
+
out.push(`### ${SEV_LABEL[d.severity]} \u2014 ${d.title}`, "");
|
|
2846
|
+
out.push(`\`${d.code}\``, "");
|
|
2847
|
+
out.push(`**What:** ${d.detail}`, "");
|
|
2848
|
+
out.push(`**Why:** ${d.cause}`, "");
|
|
2849
|
+
out.push(`**Fix:** ${d.remediation.summary}`, "");
|
|
2850
|
+
if (!tldr) {
|
|
2851
|
+
if (d.remediation.steps?.length) {
|
|
2852
|
+
for (const step of d.remediation.steps) out.push(`- ${step}`);
|
|
2853
|
+
out.push("");
|
|
2854
|
+
}
|
|
2855
|
+
for (const cmd of d.remediation.commands ?? []) {
|
|
2856
|
+
const body = cmd.sql ?? cmd.shell ?? "";
|
|
2857
|
+
const lang = cmd.sql ? "sql" : "sh";
|
|
2858
|
+
if (cmd.label) out.push(`_${cmd.label}:_`);
|
|
2859
|
+
out.push("```" + lang, body, "```", "");
|
|
2860
|
+
}
|
|
2861
|
+
if (d.docsUrl) out.push(`\u{1F4D6} [PostgreSQL docs](${d.docsUrl})`, "");
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
return out;
|
|
2865
|
+
}
|
|
2866
|
+
function summarizeCounts(diagnostics) {
|
|
2867
|
+
const counts = { error: 0, warn: 0, info: 0 };
|
|
2868
|
+
for (const d of diagnostics) counts[d.severity]++;
|
|
2869
|
+
if (diagnostics.length === 0) return "none";
|
|
2870
|
+
return `${counts.error} critical, ${counts.warn} warning(s), ${counts.info} note(s)`;
|
|
2871
|
+
}
|
|
2872
|
+
var { createColors, isColorSupported } = pc;
|
|
2873
|
+
var active = createColors(isColorSupported);
|
|
2874
|
+
function colors() {
|
|
2875
|
+
return active;
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
// src/report/terminal.ts
|
|
2879
|
+
var SEV_TAG = { error: "CRITICAL", warn: "WARNING", info: "NOTE" };
|
|
2880
|
+
function sevColor(sev, text) {
|
|
2881
|
+
const c = colors();
|
|
2882
|
+
if (sev === "error") return c.red(c.bold(text));
|
|
2883
|
+
if (sev === "warn") return c.yellow(text);
|
|
2884
|
+
return c.cyan(text);
|
|
2885
|
+
}
|
|
2886
|
+
function heat(node, text) {
|
|
2887
|
+
const c = colors();
|
|
2888
|
+
const pct = node.metrics.pctOfTotal;
|
|
2889
|
+
if (pct === void 0) return text;
|
|
2890
|
+
if (pct >= 50) return c.red(c.bold(text));
|
|
2891
|
+
if (pct >= 20) return c.yellow(text);
|
|
2892
|
+
if (pct >= 5) return text;
|
|
2893
|
+
return c.dim(text);
|
|
2894
|
+
}
|
|
2895
|
+
function bar(pct, width = 8) {
|
|
2896
|
+
const filled = Math.round(pct / 100 * width);
|
|
2897
|
+
return "\u2587".repeat(Math.min(filled, width)) + "\u2581".repeat(Math.max(width - filled, 0));
|
|
2898
|
+
}
|
|
2899
|
+
function renderTerminal(result, opts = {}) {
|
|
2900
|
+
const c = colors();
|
|
2901
|
+
const { tree, diagnostics, bottlenecks: bottlenecks2 } = result;
|
|
2902
|
+
const glyphs = opts.ascii ? ASCII_TREE : UNICODE_TREE;
|
|
2903
|
+
const out = [];
|
|
2904
|
+
out.push(c.bold("pg-explain report"));
|
|
2905
|
+
out.push(`${c.bold("Verdict:")} ${verdictColored(result)}`);
|
|
2906
|
+
out.push("");
|
|
2907
|
+
if (opts.tldr) {
|
|
2908
|
+
out.push(...findingsBlock(diagnostics, opts));
|
|
2909
|
+
return `${out.join("\n").trimEnd()}
|
|
2910
|
+
`;
|
|
2911
|
+
}
|
|
2912
|
+
out.push(c.bold("Plan tree"));
|
|
2913
|
+
for (const { node, prefix } of treeLines(tree, glyphs)) {
|
|
2914
|
+
const heatBar = opts.bars !== false && node.metrics.pctOfTotal !== void 0 ? ` ${c.dim(bar(node.metrics.pctOfTotal))}` : "";
|
|
2915
|
+
out.push(
|
|
2916
|
+
`${c.dim(prefix)}${heat(node, nodeLabel(node))}${heatBar} ${c.dim(nodeSummary(node))}`
|
|
2917
|
+
);
|
|
2918
|
+
}
|
|
2919
|
+
out.push("");
|
|
2920
|
+
const ranked = bottlenecks2.filter((n) => (n.metrics.selfMs ?? 0) > 0);
|
|
2921
|
+
if (ranked.length) {
|
|
2922
|
+
out.push(c.bold("Bottlenecks (by self time)"));
|
|
2923
|
+
ranked.forEach((node, i) => {
|
|
2924
|
+
const pct = node.metrics.pctOfTotal !== void 0 ? `${node.metrics.pctOfTotal.toFixed(0)}%` : "\u2014";
|
|
2925
|
+
out.push(
|
|
2926
|
+
` ${i + 1}. ${heat(node, nodeLabel(node))} \u2014 ${fmtMs(node.metrics.selfMs ?? 0)} (${pct})`
|
|
2927
|
+
);
|
|
2928
|
+
});
|
|
2929
|
+
out.push("");
|
|
2930
|
+
}
|
|
2931
|
+
out.push(...findingsBlock(diagnostics, opts));
|
|
2932
|
+
const ms = executionMs(tree);
|
|
2933
|
+
if (ms !== void 0) out.push(c.dim(`Total execution time: ${fmtMs(ms)}`));
|
|
2934
|
+
return `${out.join("\n").trimEnd()}
|
|
2935
|
+
`;
|
|
2936
|
+
}
|
|
2937
|
+
function verdictColored(result) {
|
|
2938
|
+
if (result.worstSeverity === null) return colors().green(result.verdict);
|
|
2939
|
+
return sevColor(result.worstSeverity, result.verdict);
|
|
2940
|
+
}
|
|
2941
|
+
function findingsBlock(diagnostics, opts) {
|
|
2942
|
+
const c = colors();
|
|
2943
|
+
const out = [c.bold("Findings")];
|
|
2944
|
+
if (diagnostics.length === 0) {
|
|
2945
|
+
out.push(` ${c.green("No anti-patterns detected.")}`, "");
|
|
2946
|
+
return out;
|
|
2947
|
+
}
|
|
2948
|
+
for (const d of diagnostics) {
|
|
2949
|
+
out.push("");
|
|
2950
|
+
out.push(
|
|
2951
|
+
`${sevColor(d.severity, `[${SEV_TAG[d.severity]}]`)} ${c.bold(d.title)} ${c.dim(d.code)}`
|
|
2952
|
+
);
|
|
2953
|
+
out.push(` ${c.dim("What:")} ${d.detail}`);
|
|
2954
|
+
out.push(` ${c.dim("Why: ")} ${d.cause}`);
|
|
2955
|
+
out.push(` ${c.dim("Fix: ")} ${d.remediation.summary}`);
|
|
2956
|
+
if (!opts.tldr) {
|
|
2957
|
+
for (const step of d.remediation.steps ?? []) out.push(` - ${step}`);
|
|
2958
|
+
for (const cmd of d.remediation.commands ?? []) {
|
|
2959
|
+
const body = cmd.sql ?? cmd.shell ?? "";
|
|
2960
|
+
const label = cmd.label ? `${c.dim(`${cmd.label}:`)} ` : "";
|
|
2961
|
+
out.push(` ${label}${c.green(body)}`);
|
|
2962
|
+
}
|
|
2963
|
+
if (d.docsUrl) out.push(` ${c.dim(`docs: ${d.docsUrl}`)}`);
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
out.push("");
|
|
2967
|
+
return out;
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
// src/report/render.ts
|
|
2971
|
+
var FORMATS = ["terminal", "markdown", "json", "html", "text"];
|
|
2972
|
+
function render(result, opts) {
|
|
2973
|
+
switch (opts.format) {
|
|
2974
|
+
case "markdown":
|
|
2975
|
+
return renderMarkdown(result, { tldr: opts.tldr });
|
|
2976
|
+
case "json":
|
|
2977
|
+
return renderJson(result, opts.pretty ?? true);
|
|
2978
|
+
case "html":
|
|
2979
|
+
return renderHtml(result);
|
|
2980
|
+
case "text":
|
|
2981
|
+
return renderTerminal(result, { ascii: true, bars: false, tldr: opts.tldr });
|
|
2982
|
+
default:
|
|
2983
|
+
return renderTerminal(result, { ascii: opts.ascii, tldr: opts.tldr });
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
|
+
// src/index.ts
|
|
2988
|
+
function analyze(input, options = {}) {
|
|
2989
|
+
const trees = parseExplain(input);
|
|
2990
|
+
const tree = selectStatement(trees, options.statement);
|
|
2991
|
+
if (options.redact) redactPlanTree(tree);
|
|
2992
|
+
computeMetrics(tree);
|
|
2993
|
+
const result = runAdvisor(tree, options.config ?? DEFAULT_CONFIG);
|
|
2994
|
+
const extra = planNotices(tree);
|
|
2995
|
+
if (options.sql) extra.push(...analyzeLocks(options.sql, tree));
|
|
2996
|
+
if (extra.length) {
|
|
2997
|
+
result.diagnostics = [...result.diagnostics, ...extra].sort(bySeverity);
|
|
2998
|
+
result.worstSeverity = result.diagnostics.reduce(
|
|
2999
|
+
(worst, d) => worst === null ? d.severity : maxSeverity(worst, d.severity),
|
|
3000
|
+
null
|
|
3001
|
+
);
|
|
3002
|
+
}
|
|
3003
|
+
return result;
|
|
3004
|
+
}
|
|
3005
|
+
function selectStatement(trees, statement) {
|
|
3006
|
+
if (statement !== void 0) {
|
|
3007
|
+
const tree = trees[statement - 1];
|
|
3008
|
+
if (!tree) {
|
|
3009
|
+
throw opError("PGX_MULTIPLE_STATEMENTS", {
|
|
3010
|
+
detail: `--statement ${statement} is out of range; the input has ${trees.length} statement(s).`
|
|
3011
|
+
});
|
|
3012
|
+
}
|
|
3013
|
+
return tree;
|
|
3014
|
+
}
|
|
3015
|
+
const first = trees[0];
|
|
3016
|
+
if (!first) throw opError("PGX_UNEXPECTED_PLAN_SHAPE");
|
|
3017
|
+
return first;
|
|
3018
|
+
}
|
|
3019
|
+
function planNotices(tree) {
|
|
3020
|
+
const notices = [];
|
|
3021
|
+
if (!tree.hasAnalyze) notices.push(opDiagnostic("PGX_COST_ONLY_PLAN"));
|
|
3022
|
+
else if (!tree.hasBuffers) notices.push(opDiagnostic("PGX_NO_BUFFERS"));
|
|
3023
|
+
const nodes = flatten(tree.root);
|
|
3024
|
+
const trivial = nodes.length === 1 && /^(Result|Values? Scan)$/.test(tree.root.nodeType);
|
|
3025
|
+
if (trivial) notices.push(opDiagnostic("PGX_EMPTY_PLAN"));
|
|
3026
|
+
return notices;
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
// src/sql/extract.ts
|
|
3030
|
+
var DML = /* @__PURE__ */ new Set(["SELECT", "INSERT", "UPDATE", "DELETE", "MERGE", "VALUES", "TABLE", "WITH"]);
|
|
3031
|
+
function classifyStatement(sql) {
|
|
3032
|
+
const kw = firstKeyword(sql);
|
|
3033
|
+
if (!kw) return "empty";
|
|
3034
|
+
if (kw === "DO") return "do-block";
|
|
3035
|
+
if (DML.has(kw) || kw === "EXECUTE") return "explainable";
|
|
3036
|
+
return "utility";
|
|
3037
|
+
}
|
|
3038
|
+
function extractAnalyzableUnits(sql) {
|
|
3039
|
+
const units = [];
|
|
3040
|
+
for (const stmt of splitStatements(sql)) {
|
|
3041
|
+
const cls = classifyStatement(stmt);
|
|
3042
|
+
if (cls === "empty") continue;
|
|
3043
|
+
if (cls === "explainable") {
|
|
3044
|
+
units.push({ kind: "explainable", label: unitLabel(stmt), sql: stmt });
|
|
3045
|
+
} else if (cls === "do-block") {
|
|
3046
|
+
units.push(...extractFromDo(stmt));
|
|
3047
|
+
} else {
|
|
3048
|
+
const kw = firstKeyword(stmt);
|
|
3049
|
+
units.push({
|
|
3050
|
+
kind: "skipped",
|
|
3051
|
+
label: `${kw} \u2026`,
|
|
3052
|
+
reason: `EXPLAIN cannot analyze a ${kw} statement (it is a utility/transaction-control command, not an optimizable query).`
|
|
3053
|
+
});
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
return units;
|
|
3057
|
+
}
|
|
3058
|
+
function extractFromDo(doSql) {
|
|
3059
|
+
const body = dollarBody(doSql);
|
|
3060
|
+
if (body === null) {
|
|
3061
|
+
return [
|
|
3062
|
+
{ kind: "skipped", label: "DO block", reason: "Could not find the block body ($$ \u2026 $$)." }
|
|
3063
|
+
];
|
|
3064
|
+
}
|
|
3065
|
+
const out = [];
|
|
3066
|
+
for (const frag of splitStatements(body)) {
|
|
3067
|
+
const stripped = stripControl(frag);
|
|
3068
|
+
if (!stripped) continue;
|
|
3069
|
+
const kw = firstKeyword(stripped.sql);
|
|
3070
|
+
if (kw === "EXECUTE") {
|
|
3071
|
+
out.push({
|
|
3072
|
+
kind: "skipped",
|
|
3073
|
+
label: `${stripped.context}EXECUTE (dynamic SQL)`,
|
|
3074
|
+
reason: "Dynamic SQL built at runtime \u2014 the statement text isn't known statically, so it can't be analyzed."
|
|
3075
|
+
});
|
|
3076
|
+
continue;
|
|
3077
|
+
}
|
|
3078
|
+
if (DML.has(kw)) {
|
|
3079
|
+
const unit = {
|
|
3080
|
+
kind: "explainable",
|
|
3081
|
+
label: stripped.context + unitLabel(stripped.sql),
|
|
3082
|
+
sql: stripped.sql
|
|
3083
|
+
};
|
|
3084
|
+
if (stripped.loop) unit.loopNote = "runs once per loop iteration in the block";
|
|
3085
|
+
out.push(unit);
|
|
3086
|
+
}
|
|
3087
|
+
}
|
|
3088
|
+
if (out.length === 0) {
|
|
3089
|
+
out.push({
|
|
3090
|
+
kind: "skipped",
|
|
3091
|
+
label: "DO block",
|
|
3092
|
+
reason: "No top-level DML statements found to analyze."
|
|
3093
|
+
});
|
|
3094
|
+
}
|
|
3095
|
+
return out;
|
|
3096
|
+
}
|
|
3097
|
+
function stripControl(frag) {
|
|
3098
|
+
let rest = frag;
|
|
3099
|
+
let context = "";
|
|
3100
|
+
let loop = false;
|
|
3101
|
+
for (let guard = 0; guard < 8; guard++) {
|
|
3102
|
+
rest = rest.replace(/^\s+/, "");
|
|
3103
|
+
const masked = maskNonCode(rest);
|
|
3104
|
+
const kw2 = (/^[A-Za-z_]+/.exec(masked)?.[0] ?? "").toUpperCase();
|
|
3105
|
+
if (kw2 === "IF" || kw2 === "ELSIF") {
|
|
3106
|
+
const then = /\bTHEN\b/i.exec(masked);
|
|
3107
|
+
if (!then) break;
|
|
3108
|
+
context += kw2 === "IF" ? "IF-branch \u203A " : "ELSIF-branch \u203A ";
|
|
3109
|
+
rest = rest.slice(then.index + 4);
|
|
3110
|
+
} else if (kw2 === "ELSE") {
|
|
3111
|
+
context += "ELSE-branch \u203A ";
|
|
3112
|
+
rest = rest.replace(/^\s*ELSE\b/i, "");
|
|
3113
|
+
} else if (kw2 === "FOR" || kw2 === "WHILE") {
|
|
3114
|
+
const lp = /\bLOOP\b/i.exec(masked);
|
|
3115
|
+
if (!lp) break;
|
|
3116
|
+
loop = true;
|
|
3117
|
+
context += "loop \u203A ";
|
|
3118
|
+
rest = rest.slice(lp.index + 4);
|
|
3119
|
+
} else if (kw2 === "LOOP") {
|
|
3120
|
+
loop = true;
|
|
3121
|
+
context += "loop \u203A ";
|
|
3122
|
+
rest = rest.replace(/^\s*LOOP\b/i, "");
|
|
3123
|
+
} else if (kw2 === "BEGIN" || kw2 === "THEN") {
|
|
3124
|
+
rest = rest.replace(/^\s*(BEGIN|THEN)\b/i, "");
|
|
3125
|
+
} else {
|
|
3126
|
+
break;
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
rest = rest.replace(/^\s+/, "").replace(/;\s*$/, "").trim();
|
|
3130
|
+
if (!rest) return null;
|
|
3131
|
+
const kw = firstKeyword(rest);
|
|
3132
|
+
if (kw === "PERFORM") return { context, sql: rest.replace(/^\s*PERFORM\b/i, "SELECT"), loop };
|
|
3133
|
+
if (DML.has(kw) || kw === "EXECUTE") return { context, sql: rest, loop };
|
|
3134
|
+
return null;
|
|
3135
|
+
}
|
|
3136
|
+
function firstKeyword(sql) {
|
|
3137
|
+
const m = /^[A-Za-z_]+/.exec(maskNonCode(sql).trim());
|
|
3138
|
+
return m ? m[0].toUpperCase() : "";
|
|
3139
|
+
}
|
|
3140
|
+
function unitLabel(stmt) {
|
|
3141
|
+
const kw = firstKeyword(stmt);
|
|
3142
|
+
const t = targetTable2(stmt, kw);
|
|
3143
|
+
return t ? `${kw} ${t}` : kw || "statement";
|
|
3144
|
+
}
|
|
3145
|
+
function targetTable2(stmt, kw) {
|
|
3146
|
+
const re = kw === "DELETE" ? /\bDELETE\s+FROM\s+([A-Za-z_][\w.]*)/i : kw === "INSERT" ? /\bINSERT\s+INTO\s+([A-Za-z_][\w.]*)/i : kw === "UPDATE" ? /\bUPDATE\s+(?:ONLY\s+)?([A-Za-z_][\w.]*)/i : void 0;
|
|
3147
|
+
return re ? re.exec(stmt)?.[1] ?? void 0 : void 0;
|
|
3148
|
+
}
|
|
3149
|
+
function dollarBody(doSql) {
|
|
3150
|
+
const m = /\$([A-Za-z_]*)\$/.exec(doSql);
|
|
3151
|
+
if (!m) return null;
|
|
3152
|
+
const tag = m[0];
|
|
3153
|
+
const start = m.index + tag.length;
|
|
3154
|
+
const end = doSql.indexOf(tag, start);
|
|
3155
|
+
return end < 0 ? null : doSql.slice(start, end);
|
|
3156
|
+
}
|
|
3157
|
+
function maskNonCode(sql) {
|
|
3158
|
+
const out = sql.split("");
|
|
3159
|
+
const n = sql.length;
|
|
3160
|
+
let i = 0;
|
|
3161
|
+
const blank = (a, b) => {
|
|
3162
|
+
for (let k = a; k < b && k < n; k++) if (out[k] !== "\n") out[k] = " ";
|
|
3163
|
+
};
|
|
3164
|
+
while (i < n) {
|
|
3165
|
+
const two = sql.slice(i, i + 2);
|
|
3166
|
+
if (two === "--") {
|
|
3167
|
+
let j = sql.indexOf("\n", i);
|
|
3168
|
+
if (j < 0) j = n;
|
|
3169
|
+
blank(i, j);
|
|
3170
|
+
i = j;
|
|
3171
|
+
} else if (two === "/*") {
|
|
3172
|
+
let j = sql.indexOf("*/", i + 2);
|
|
3173
|
+
j = j < 0 ? n : j + 2;
|
|
3174
|
+
blank(i, j);
|
|
3175
|
+
i = j;
|
|
3176
|
+
} else if (sql[i] === "'" || sql[i] === '"') {
|
|
3177
|
+
const q = sql[i];
|
|
3178
|
+
let j = i + 1;
|
|
3179
|
+
while (j < n) {
|
|
3180
|
+
if (sql[j] === q) {
|
|
3181
|
+
if (sql[j + 1] === q) j += 2;
|
|
3182
|
+
else {
|
|
3183
|
+
j++;
|
|
3184
|
+
break;
|
|
3185
|
+
}
|
|
3186
|
+
} else j++;
|
|
3187
|
+
}
|
|
3188
|
+
blank(i, j);
|
|
3189
|
+
i = j;
|
|
3190
|
+
} else if (sql[i] === "$") {
|
|
3191
|
+
const m = /^\$[A-Za-z_]*\$/.exec(sql.slice(i));
|
|
3192
|
+
if (m) {
|
|
3193
|
+
const tag = m[0];
|
|
3194
|
+
let j = sql.indexOf(tag, i + tag.length);
|
|
3195
|
+
j = j < 0 ? n : j + tag.length;
|
|
3196
|
+
blank(i, j);
|
|
3197
|
+
i = j;
|
|
3198
|
+
} else i++;
|
|
3199
|
+
} else i++;
|
|
3200
|
+
}
|
|
3201
|
+
return out.join("");
|
|
3202
|
+
}
|
|
3203
|
+
|
|
3204
|
+
// src/commands/script.ts
|
|
3205
|
+
async function analyzeScript(connection, sql, opts) {
|
|
3206
|
+
const units = extractAnalyzableUnits(sql);
|
|
3207
|
+
const explainable = units.filter((u) => u.kind === "explainable");
|
|
3208
|
+
const exec = explainable.length ? await explainScript(
|
|
3209
|
+
connection,
|
|
3210
|
+
explainable.map((u) => ({
|
|
3211
|
+
label: u.label,
|
|
3212
|
+
sql: u.sql,
|
|
3213
|
+
...u.loopNote ? { loopNote: u.loopNote } : {}
|
|
3214
|
+
})),
|
|
3215
|
+
{
|
|
3216
|
+
statementTimeoutMs: opts.statementTimeoutMs,
|
|
3217
|
+
lockTimeoutMs: opts.lockTimeoutMs,
|
|
3218
|
+
verbose: opts.verbose,
|
|
3219
|
+
settings: opts.settings
|
|
3220
|
+
}
|
|
3221
|
+
) : null;
|
|
3222
|
+
let ei = 0;
|
|
3223
|
+
const out = units.map((u) => {
|
|
3224
|
+
if (u.kind === "skipped") return { label: u.label, status: "skipped", reason: u.reason };
|
|
3225
|
+
const r = exec?.units[ei++];
|
|
3226
|
+
if (!r) return { label: u.label, status: "skipped", reason: "not analyzed" };
|
|
3227
|
+
if (r.error) {
|
|
3228
|
+
return {
|
|
3229
|
+
label: u.label,
|
|
3230
|
+
status: "error",
|
|
3231
|
+
reason: r.error.detail,
|
|
3232
|
+
errorCode: r.error.code,
|
|
3233
|
+
...r.loopNote ? { loopNote: r.loopNote } : {}
|
|
3234
|
+
};
|
|
3235
|
+
}
|
|
3236
|
+
const result = analyze(r.planJson, {
|
|
3237
|
+
sql: u.sql,
|
|
3238
|
+
config: opts.config,
|
|
3239
|
+
redact: opts.redact
|
|
3240
|
+
});
|
|
3241
|
+
return {
|
|
3242
|
+
label: u.label,
|
|
3243
|
+
status: "analyzed",
|
|
3244
|
+
result,
|
|
3245
|
+
report: buildReport(result),
|
|
3246
|
+
...r.loopNote ? { loopNote: r.loopNote } : {}
|
|
3247
|
+
};
|
|
3248
|
+
});
|
|
3249
|
+
return { executed: false, units: out, ...exec ? { serverMajor: exec.caps.major } : {} };
|
|
3250
|
+
}
|
|
3251
|
+
|
|
3252
|
+
// src/core/diff.ts
|
|
3253
|
+
function weight(node) {
|
|
3254
|
+
if (node.metrics.selfMs !== void 0) return node.metrics.selfMs;
|
|
3255
|
+
const own = node.totalCost ?? 0;
|
|
3256
|
+
let children = 0;
|
|
3257
|
+
for (const c of node.children) children += c.totalCost ?? 0;
|
|
3258
|
+
return Math.max(own - children, 0);
|
|
3259
|
+
}
|
|
3260
|
+
function signature(node) {
|
|
3261
|
+
return nodeLabel(node);
|
|
3262
|
+
}
|
|
3263
|
+
function weightBySignature(result) {
|
|
3264
|
+
const map = /* @__PURE__ */ new Map();
|
|
3265
|
+
walk(result.tree.root, (node) => {
|
|
3266
|
+
const key = signature(node);
|
|
3267
|
+
map.set(key, (map.get(key) ?? 0) + weight(node));
|
|
3268
|
+
});
|
|
3269
|
+
return map;
|
|
3270
|
+
}
|
|
3271
|
+
function findingKey(d) {
|
|
3272
|
+
return `${d.code}|${d.location?.relation ?? ""}`;
|
|
3273
|
+
}
|
|
3274
|
+
function diffAnalyses(before, after) {
|
|
3275
|
+
const beforeMs = executionMs(before.tree);
|
|
3276
|
+
const afterMs = executionMs(after.tree);
|
|
3277
|
+
const timed = before.tree.hasAnalyze && after.tree.hasAnalyze;
|
|
3278
|
+
let execDeltaMs;
|
|
3279
|
+
let execDeltaPct;
|
|
3280
|
+
if (beforeMs !== void 0 && afterMs !== void 0) {
|
|
3281
|
+
execDeltaMs = afterMs - beforeMs;
|
|
3282
|
+
execDeltaPct = beforeMs > 0 ? 100 * execDeltaMs / beforeMs : void 0;
|
|
3283
|
+
}
|
|
3284
|
+
const beforeMap = weightBySignature(before);
|
|
3285
|
+
const afterMap = weightBySignature(after);
|
|
3286
|
+
const keys = /* @__PURE__ */ new Set([...beforeMap.keys(), ...afterMap.keys()]);
|
|
3287
|
+
const regressed = [];
|
|
3288
|
+
const improved = [];
|
|
3289
|
+
const added = [];
|
|
3290
|
+
const removed = [];
|
|
3291
|
+
for (const key of keys) {
|
|
3292
|
+
const b = beforeMap.get(key);
|
|
3293
|
+
const a = afterMap.get(key);
|
|
3294
|
+
const beforeVal = b ?? 0;
|
|
3295
|
+
const afterVal = a ?? 0;
|
|
3296
|
+
const deltaMs = afterVal - beforeVal;
|
|
3297
|
+
const deltaPct = beforeVal > 0 ? 100 * deltaMs / beforeVal : null;
|
|
3298
|
+
const entry = {
|
|
3299
|
+
signature: key,
|
|
3300
|
+
beforeMs: beforeVal,
|
|
3301
|
+
afterMs: afterVal,
|
|
3302
|
+
deltaMs,
|
|
3303
|
+
deltaPct
|
|
3304
|
+
};
|
|
3305
|
+
if (b === void 0) added.push(entry);
|
|
3306
|
+
else if (a === void 0) removed.push(entry);
|
|
3307
|
+
else if (deltaMs > 1e-4) regressed.push(entry);
|
|
3308
|
+
else if (deltaMs < -1e-4) improved.push(entry);
|
|
3309
|
+
}
|
|
3310
|
+
regressed.sort((x, y) => y.deltaMs - x.deltaMs);
|
|
3311
|
+
improved.sort((x, y) => x.deltaMs - y.deltaMs);
|
|
3312
|
+
added.sort((x, y) => y.afterMs - x.afterMs);
|
|
3313
|
+
removed.sort((x, y) => y.beforeMs - x.beforeMs);
|
|
3314
|
+
const beforeKeys = new Set(before.diagnostics.map(findingKey));
|
|
3315
|
+
const afterKeys = new Set(after.diagnostics.map(findingKey));
|
|
3316
|
+
const newFindings = after.diagnostics.filter((d) => !beforeKeys.has(findingKey(d)));
|
|
3317
|
+
const resolvedFindings = before.diagnostics.filter((d) => !afterKeys.has(findingKey(d)));
|
|
3318
|
+
return {
|
|
3319
|
+
beforeMs,
|
|
3320
|
+
afterMs,
|
|
3321
|
+
execDeltaMs,
|
|
3322
|
+
execDeltaPct,
|
|
3323
|
+
timed,
|
|
3324
|
+
regressed,
|
|
3325
|
+
improved,
|
|
3326
|
+
added,
|
|
3327
|
+
removed,
|
|
3328
|
+
newFindings,
|
|
3329
|
+
resolvedFindings
|
|
3330
|
+
};
|
|
3331
|
+
}
|
|
3332
|
+
|
|
3333
|
+
// src/server/schema.ts
|
|
3334
|
+
var CATALOG_SQL = `
|
|
3335
|
+
SELECT n.nspname AS schema,
|
|
3336
|
+
c.relname AS name,
|
|
3337
|
+
array_agg(a.attname::text ORDER BY a.attnum) AS columns
|
|
3338
|
+
FROM pg_class c
|
|
3339
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
3340
|
+
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
|
|
3341
|
+
WHERE c.relkind IN ('r', 'p', 'v', 'm', 'f')
|
|
3342
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
3343
|
+
GROUP BY n.nspname, c.relname
|
|
3344
|
+
ORDER BY n.nspname, c.relname;
|
|
3345
|
+
`;
|
|
3346
|
+
async function catalog(connection) {
|
|
3347
|
+
const rows = await queryReadOnly(
|
|
3348
|
+
connection,
|
|
3349
|
+
CATALOG_SQL
|
|
3350
|
+
);
|
|
3351
|
+
return rows.map((r) => ({ schema: r.schema, name: r.name, columns: r.columns ?? [] }));
|
|
3352
|
+
}
|
|
3353
|
+
var SQL = `
|
|
3354
|
+
SELECT c.relname AS relation,
|
|
3355
|
+
c.reltuples::bigint AS "estRows",
|
|
3356
|
+
pg_total_relation_size(c.oid) AS "totalBytes",
|
|
3357
|
+
pg_relation_size(c.oid) AS "tableBytes",
|
|
3358
|
+
(SELECT array_agg(ir.relname::text ORDER BY ir.relname)
|
|
3359
|
+
FROM pg_index i JOIN pg_class ir ON ir.oid = i.indexrelid
|
|
3360
|
+
WHERE i.indrelid = c.oid) AS indexes,
|
|
3361
|
+
s.last_vacuum AS "lastVacuum",
|
|
3362
|
+
s.last_autovacuum AS "lastAutovacuum",
|
|
3363
|
+
s.last_analyze AS "lastAnalyze",
|
|
3364
|
+
s.last_autoanalyze AS "lastAutoanalyze",
|
|
3365
|
+
s.n_mod_since_analyze AS "modSinceAnalyze",
|
|
3366
|
+
s.n_live_tup AS "liveTup"
|
|
3367
|
+
FROM pg_class c
|
|
3368
|
+
LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
|
|
3369
|
+
WHERE c.relkind IN ('r', 'p') AND c.relname = ANY($1);
|
|
3370
|
+
`;
|
|
3371
|
+
var toNum = (v) => v == null ? null : Number(v);
|
|
3372
|
+
async function relationStats(connection, relations) {
|
|
3373
|
+
const names = [...new Set(relations.filter(Boolean))];
|
|
3374
|
+
if (names.length === 0) return [];
|
|
3375
|
+
const rows = await queryReadOnly(connection, SQL, [names]);
|
|
3376
|
+
return rows.map((r) => ({
|
|
3377
|
+
relation: r.relation,
|
|
3378
|
+
estRows: toNum(r.estRows),
|
|
3379
|
+
totalBytes: toNum(r.totalBytes),
|
|
3380
|
+
tableBytes: toNum(r.tableBytes),
|
|
3381
|
+
indexes: r.indexes ?? [],
|
|
3382
|
+
lastVacuum: r.lastVacuum,
|
|
3383
|
+
lastAutovacuum: r.lastAutovacuum,
|
|
3384
|
+
lastAnalyze: r.lastAnalyze,
|
|
3385
|
+
lastAutoanalyze: r.lastAutoanalyze,
|
|
3386
|
+
modSinceAnalyze: toNum(r.modSinceAnalyze),
|
|
3387
|
+
liveTup: toNum(r.liveTup)
|
|
3388
|
+
}));
|
|
3389
|
+
}
|
|
3390
|
+
|
|
3391
|
+
// src/diagnostics/stale-stats.ts
|
|
3392
|
+
var RULE_ID = "PGX_STALE_STATISTICS";
|
|
3393
|
+
var DOCS4 = "https://www.postgresql.org/docs/current/routine-vacuuming.html#VACUUM-FOR-STATISTICS";
|
|
3394
|
+
var MIN_ROWS = 1e3;
|
|
3395
|
+
function staleStatsFindings(stats, config) {
|
|
3396
|
+
if (config.rules[RULE_ID]?.enabled === false) return [];
|
|
3397
|
+
const severity = config.rules[RULE_ID]?.severity ?? "warn";
|
|
3398
|
+
const ratioLimit = config.thresholds.staleStatsModRatio;
|
|
3399
|
+
const out = [];
|
|
3400
|
+
for (const s of stats) {
|
|
3401
|
+
const rows = s.liveTup ?? s.estRows ?? 0;
|
|
3402
|
+
if (rows < MIN_ROWS) continue;
|
|
3403
|
+
const neverAnalyzed = !s.lastAnalyze && !s.lastAutoanalyze;
|
|
3404
|
+
const modRatio = s.modSinceAnalyze != null && rows > 0 ? s.modSinceAnalyze / rows : 0;
|
|
3405
|
+
if (!neverAnalyzed && modRatio < ratioLimit) continue;
|
|
3406
|
+
out.push(
|
|
3407
|
+
finding(RULE_ID, severity, {
|
|
3408
|
+
title: neverAnalyzed ? `Table ${s.relation} has never been analyzed` : `Planner statistics on ${s.relation} are stale`,
|
|
3409
|
+
detail: neverAnalyzed ? `${s.relation} (~${Math.round(rows).toLocaleString()} rows) has no planner statistics \u2014 pg_stat_user_tables shows no manual or auto ANALYZE.` : `${s.modSinceAnalyze?.toLocaleString()} rows of ${s.relation} changed since its last ANALYZE (${(modRatio * 100).toFixed(0)}% of ~${Math.round(rows).toLocaleString()} live rows).`,
|
|
3410
|
+
cause: "The planner chooses plans from per-table statistics. When they are missing or stale, row estimates drift, which cascades into bad join orders, wrong scan types, and misestimates like PGX_ROW_MISESTIMATE.",
|
|
3411
|
+
remediation: {
|
|
3412
|
+
summary: `Run ANALYZE on ${s.relation}, and if it keeps going stale, lower its autovacuum analyze threshold.`,
|
|
3413
|
+
steps: [
|
|
3414
|
+
"ANALYZE the table now to refresh statistics.",
|
|
3415
|
+
"If the table churns heavily, tune per-table autovacuum settings so auto-analyze keeps up."
|
|
3416
|
+
],
|
|
3417
|
+
commands: [
|
|
3418
|
+
{ label: "Refresh statistics", sql: `ANALYZE ${s.relation};` },
|
|
3419
|
+
{
|
|
3420
|
+
label: "Analyze more eagerly on churny tables",
|
|
3421
|
+
sql: `ALTER TABLE ${s.relation} SET (autovacuum_analyze_scale_factor = 0.02);`
|
|
3422
|
+
}
|
|
3423
|
+
]
|
|
3424
|
+
},
|
|
3425
|
+
docsUrl: DOCS4,
|
|
3426
|
+
meta: {
|
|
3427
|
+
relation: s.relation,
|
|
3428
|
+
modSinceAnalyze: s.modSinceAnalyze ?? 0,
|
|
3429
|
+
liveTup: s.liveTup ?? 0
|
|
3430
|
+
}
|
|
3431
|
+
})
|
|
3432
|
+
);
|
|
3433
|
+
}
|
|
3434
|
+
return out;
|
|
3435
|
+
}
|
|
3436
|
+
async function checkStaleStats(connection, result, config) {
|
|
3437
|
+
try {
|
|
3438
|
+
const relations = [
|
|
3439
|
+
...new Set(
|
|
3440
|
+
flatten(result.tree.root).map((n) => n.relationName).filter((r) => !!r)
|
|
3441
|
+
)
|
|
3442
|
+
];
|
|
3443
|
+
if (!relations.length) return;
|
|
3444
|
+
appendFindings(result, staleStatsFindings(await relationStats(connection, relations), config));
|
|
3445
|
+
} catch {
|
|
3446
|
+
}
|
|
3447
|
+
}
|
|
3448
|
+
function appendFindings(result, extra) {
|
|
3449
|
+
if (!extra.length) return;
|
|
3450
|
+
result.diagnostics = [...result.diagnostics, ...extra].sort(bySeverity);
|
|
3451
|
+
result.worstSeverity = result.diagnostics.reduce(
|
|
3452
|
+
(worst, d) => worst === null ? d.severity : maxSeverity(worst, d.severity),
|
|
3453
|
+
null
|
|
3454
|
+
);
|
|
3455
|
+
}
|
|
3456
|
+
|
|
3457
|
+
// src/locks/live.ts
|
|
3458
|
+
var SQL2 = `
|
|
3459
|
+
SELECT a.pid,
|
|
3460
|
+
a.usename AS "user",
|
|
3461
|
+
a.state,
|
|
3462
|
+
a.wait_event_type AS "waitEventType",
|
|
3463
|
+
a.wait_event AS "waitEvent",
|
|
3464
|
+
EXTRACT(EPOCH FROM (now() - a.query_start)) AS "ageSeconds",
|
|
3465
|
+
a.query,
|
|
3466
|
+
pg_blocking_pids(a.pid) AS "blockedBy"
|
|
3467
|
+
FROM pg_stat_activity a
|
|
3468
|
+
WHERE a.backend_type = 'client backend' AND a.pid <> pg_backend_pid()
|
|
3469
|
+
ORDER BY cardinality(pg_blocking_pids(a.pid)) DESC, a.query_start NULLS LAST;
|
|
3470
|
+
`;
|
|
3471
|
+
async function liveLocks(connection, capturedAt) {
|
|
3472
|
+
const rows = await queryReadOnly(connection, SQL2);
|
|
3473
|
+
const sessions = rows.map((r) => ({
|
|
3474
|
+
pid: r.pid,
|
|
3475
|
+
user: r.user,
|
|
3476
|
+
state: r.state,
|
|
3477
|
+
waitEventType: r.waitEventType,
|
|
3478
|
+
waitEvent: r.waitEvent,
|
|
3479
|
+
ageSeconds: r.ageSeconds == null ? null : Number(r.ageSeconds),
|
|
3480
|
+
query: r.query,
|
|
3481
|
+
blockedBy: r.blockedBy ?? []
|
|
3482
|
+
}));
|
|
3483
|
+
return { sessions, blocked: sessions.filter((s) => s.blockedBy.length > 0), capturedAt };
|
|
3484
|
+
}
|
|
3485
|
+
function dataDir() {
|
|
3486
|
+
return process.env.PGEXPLAIN_DATA_DIR ?? join(homedir(), ".pgexplain");
|
|
3487
|
+
}
|
|
3488
|
+
var SCHEMA = `
|
|
3489
|
+
CREATE TABLE IF NOT EXISTS connections (
|
|
3490
|
+
id TEXT PRIMARY KEY, name TEXT NOT NULL, dsn TEXT, host TEXT, port INTEGER,
|
|
3491
|
+
database TEXT, "user" TEXT, password TEXT, sslmode TEXT, sslrootcert TEXT,
|
|
3492
|
+
created_at INTEGER NOT NULL
|
|
3493
|
+
);
|
|
3494
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
3495
|
+
id TEXT PRIMARY KEY, created_at INTEGER NOT NULL, kind TEXT NOT NULL,
|
|
3496
|
+
connection_id TEXT, label TEXT, starred INTEGER NOT NULL DEFAULT 0,
|
|
3497
|
+
baseline INTEGER NOT NULL DEFAULT 0, sql TEXT, verdict TEXT,
|
|
3498
|
+
worst_severity TEXT, exec_ms REAL, counts TEXT NOT NULL, report TEXT NOT NULL,
|
|
3499
|
+
plan_text TEXT
|
|
3500
|
+
);
|
|
3501
|
+
CREATE INDEX IF NOT EXISTS idx_runs_created ON runs(created_at DESC);
|
|
3502
|
+
`;
|
|
3503
|
+
function openStore(file) {
|
|
3504
|
+
const dir = dataDir();
|
|
3505
|
+
mkdirSync(dir, { recursive: true });
|
|
3506
|
+
const db = new Database(join(dir, "studio.db"));
|
|
3507
|
+
db.pragma("journal_mode = WAL");
|
|
3508
|
+
db.exec(SCHEMA);
|
|
3509
|
+
const runCols = db.prepare("PRAGMA table_info(runs)").all();
|
|
3510
|
+
if (!runCols.some((c) => c.name === "plan_text"))
|
|
3511
|
+
db.exec("ALTER TABLE runs ADD COLUMN plan_text TEXT");
|
|
3512
|
+
const connToPublic = (r) => ({
|
|
3513
|
+
id: r.id,
|
|
3514
|
+
name: r.name,
|
|
3515
|
+
dsn: r.dsn ?? void 0,
|
|
3516
|
+
host: r.host ?? void 0,
|
|
3517
|
+
port: r.port ?? void 0,
|
|
3518
|
+
database: r.database ?? void 0,
|
|
3519
|
+
user: r.user ?? void 0,
|
|
3520
|
+
sslmode: r.sslmode ?? void 0,
|
|
3521
|
+
sslrootcert: r.sslrootcert ?? void 0,
|
|
3522
|
+
hasPassword: !!r.password,
|
|
3523
|
+
createdAt: r.created_at
|
|
3524
|
+
});
|
|
3525
|
+
const runToSummary = (r) => ({
|
|
3526
|
+
id: r.id,
|
|
3527
|
+
createdAt: r.created_at,
|
|
3528
|
+
kind: r.kind,
|
|
3529
|
+
connectionId: r.connection_id ?? null,
|
|
3530
|
+
label: r.label ?? null,
|
|
3531
|
+
starred: !!r.starred,
|
|
3532
|
+
baseline: !!r.baseline,
|
|
3533
|
+
sql: r.sql ?? null,
|
|
3534
|
+
verdict: r.verdict ?? "",
|
|
3535
|
+
worstSeverity: r.worst_severity ?? null,
|
|
3536
|
+
execMs: r.exec_ms ?? null,
|
|
3537
|
+
counts: JSON.parse(r.counts ?? '{"error":0,"warn":0,"info":0}')
|
|
3538
|
+
});
|
|
3539
|
+
return {
|
|
3540
|
+
listConnections: () => db.prepare("SELECT * FROM connections ORDER BY created_at DESC").all().map(
|
|
3541
|
+
connToPublic
|
|
3542
|
+
),
|
|
3543
|
+
getConnection: (id) => {
|
|
3544
|
+
const r = db.prepare("SELECT * FROM connections WHERE id = ?").get(id);
|
|
3545
|
+
if (!r) return null;
|
|
3546
|
+
return {
|
|
3547
|
+
id: r.id,
|
|
3548
|
+
name: r.name,
|
|
3549
|
+
dsn: r.dsn ?? void 0,
|
|
3550
|
+
host: r.host ?? void 0,
|
|
3551
|
+
port: r.port ?? void 0,
|
|
3552
|
+
database: r.database ?? void 0,
|
|
3553
|
+
user: r.user ?? void 0,
|
|
3554
|
+
password: r.password ?? void 0,
|
|
3555
|
+
sslmode: r.sslmode ?? void 0,
|
|
3556
|
+
sslrootcert: r.sslrootcert ?? void 0
|
|
3557
|
+
};
|
|
3558
|
+
},
|
|
3559
|
+
createConnection: (input) => {
|
|
3560
|
+
const id = crypto.randomUUID();
|
|
3561
|
+
const createdAt = Date.now();
|
|
3562
|
+
db.prepare(
|
|
3563
|
+
`INSERT INTO connections (id,name,dsn,host,port,database,"user",password,sslmode,sslrootcert,created_at)
|
|
3564
|
+
VALUES (@id,@name,@dsn,@host,@port,@database,@user,@password,@sslmode,@sslrootcert,@createdAt)`
|
|
3565
|
+
).run(normalizeConn({ id, createdAt, ...input }));
|
|
3566
|
+
return connToPublic(db.prepare("SELECT * FROM connections WHERE id = ?").get(id));
|
|
3567
|
+
},
|
|
3568
|
+
updateConnection: (id, input) => {
|
|
3569
|
+
const exists = db.prepare("SELECT id FROM connections WHERE id = ?").get(id);
|
|
3570
|
+
if (!exists) return null;
|
|
3571
|
+
db.prepare(
|
|
3572
|
+
`UPDATE connections SET name=@name,dsn=@dsn,host=@host,port=@port,database=@database,
|
|
3573
|
+
"user"=@user,password=@password,sslmode=@sslmode,sslrootcert=@sslrootcert WHERE id=@id`
|
|
3574
|
+
).run(normalizeConn({ id, createdAt: 0, ...input }));
|
|
3575
|
+
return connToPublic(db.prepare("SELECT * FROM connections WHERE id = ?").get(id));
|
|
3576
|
+
},
|
|
3577
|
+
deleteConnection: (id) => db.prepare("DELETE FROM connections WHERE id = ?").run(id).changes > 0,
|
|
3578
|
+
insertRun: (input) => {
|
|
3579
|
+
const id = crypto.randomUUID();
|
|
3580
|
+
const createdAt = Date.now();
|
|
3581
|
+
db.prepare(
|
|
3582
|
+
`INSERT INTO runs (id,created_at,kind,connection_id,label,starred,baseline,sql,verdict,worst_severity,exec_ms,counts,report,plan_text)
|
|
3583
|
+
VALUES (@id,@createdAt,@kind,@connectionId,NULL,0,0,@sql,@verdict,@worstSeverity,@execMs,@counts,@report,@planText)`
|
|
3584
|
+
).run({
|
|
3585
|
+
id,
|
|
3586
|
+
createdAt,
|
|
3587
|
+
kind: input.kind,
|
|
3588
|
+
connectionId: input.connectionId ?? null,
|
|
3589
|
+
sql: input.sql ?? null,
|
|
3590
|
+
verdict: input.verdict,
|
|
3591
|
+
worstSeverity: input.worstSeverity,
|
|
3592
|
+
execMs: input.execMs,
|
|
3593
|
+
counts: JSON.stringify(input.counts),
|
|
3594
|
+
report: JSON.stringify(input.report),
|
|
3595
|
+
planText: input.planText ?? null
|
|
3596
|
+
});
|
|
3597
|
+
return getRun(db, id);
|
|
3598
|
+
},
|
|
3599
|
+
listRuns: (limit = 200) => db.prepare("SELECT * FROM runs ORDER BY created_at DESC LIMIT ?").all(limit).map(
|
|
3600
|
+
runToSummary
|
|
3601
|
+
),
|
|
3602
|
+
getRun: (id) => getRun(db, id),
|
|
3603
|
+
deleteRun: (id) => db.prepare("DELETE FROM runs WHERE id = ?").run(id).changes > 0,
|
|
3604
|
+
updateRun: (id, patch) => {
|
|
3605
|
+
const r = db.prepare("SELECT * FROM runs WHERE id = ?").get(id);
|
|
3606
|
+
if (!r) return null;
|
|
3607
|
+
if (patch.baseline === true) db.prepare("UPDATE runs SET baseline = 0").run();
|
|
3608
|
+
db.prepare(
|
|
3609
|
+
"UPDATE runs SET starred = COALESCE(@starred, starred), label = COALESCE(@label, label), baseline = COALESCE(@baseline, baseline) WHERE id = @id"
|
|
3610
|
+
).run({
|
|
3611
|
+
id,
|
|
3612
|
+
starred: patch.starred === void 0 ? null : patch.starred ? 1 : 0,
|
|
3613
|
+
label: patch.label === void 0 ? null : patch.label,
|
|
3614
|
+
baseline: patch.baseline === void 0 ? null : patch.baseline ? 1 : 0
|
|
3615
|
+
});
|
|
3616
|
+
return runToSummary(db.prepare("SELECT * FROM runs WHERE id = ?").get(id));
|
|
3617
|
+
},
|
|
3618
|
+
close: () => db.close()
|
|
3619
|
+
};
|
|
3620
|
+
function getRun(database, id) {
|
|
3621
|
+
const r = database.prepare("SELECT * FROM runs WHERE id = ?").get(id);
|
|
3622
|
+
if (!r) return null;
|
|
3623
|
+
return {
|
|
3624
|
+
...runToSummary(r),
|
|
3625
|
+
report: JSON.parse(r.report),
|
|
3626
|
+
planText: r.plan_text ?? null
|
|
3627
|
+
};
|
|
3628
|
+
}
|
|
3629
|
+
}
|
|
3630
|
+
function normalizeConn(c) {
|
|
3631
|
+
return {
|
|
3632
|
+
id: c.id,
|
|
3633
|
+
name: c.name,
|
|
3634
|
+
dsn: c.dsn ?? null,
|
|
3635
|
+
host: c.host ?? null,
|
|
3636
|
+
port: c.port ?? null,
|
|
3637
|
+
database: c.database ?? null,
|
|
3638
|
+
user: c.user ?? null,
|
|
3639
|
+
password: c.password ?? null,
|
|
3640
|
+
sslmode: c.sslmode ?? null,
|
|
3641
|
+
sslrootcert: c.sslrootcert ?? null,
|
|
3642
|
+
createdAt: c.createdAt
|
|
3643
|
+
};
|
|
3644
|
+
}
|
|
3645
|
+
|
|
3646
|
+
// src/server/settings.ts
|
|
3647
|
+
function configPath() {
|
|
3648
|
+
return join(dataDir(), "config.json");
|
|
3649
|
+
}
|
|
3650
|
+
function merge(raw) {
|
|
3651
|
+
return {
|
|
3652
|
+
thresholds: { ...DEFAULT_THRESHOLDS, ...raw.thresholds ?? {} },
|
|
3653
|
+
rules: raw.rules ?? {}
|
|
3654
|
+
};
|
|
3655
|
+
}
|
|
3656
|
+
async function readStudioConfig() {
|
|
3657
|
+
try {
|
|
3658
|
+
return merge(JSON.parse(await readFile(configPath(), "utf8")));
|
|
3659
|
+
} catch {
|
|
3660
|
+
return merge({});
|
|
3661
|
+
}
|
|
3662
|
+
}
|
|
3663
|
+
async function writeStudioConfig(raw) {
|
|
3664
|
+
const cfg = merge(raw);
|
|
3665
|
+
await mkdir(dataDir(), { recursive: true });
|
|
3666
|
+
await writeFile(configPath(), JSON.stringify(cfg, null, 2));
|
|
3667
|
+
return cfg;
|
|
3668
|
+
}
|
|
3669
|
+
function validate(schema, data) {
|
|
3670
|
+
const result = schema.safeParse(data);
|
|
3671
|
+
if (result.success) return result.data;
|
|
3672
|
+
const issues = result.error.issues.map((i) => `${i.path.join(".") || "body"}: ${i.message}`).join("; ");
|
|
3673
|
+
const diagnostic = {
|
|
3674
|
+
code: "PGX_BAD_REQUEST",
|
|
3675
|
+
domain: "operational",
|
|
3676
|
+
severity: "error",
|
|
3677
|
+
title: "Invalid request",
|
|
3678
|
+
detail: issues,
|
|
3679
|
+
cause: "The request body did not match the expected shape.",
|
|
3680
|
+
remediation: { summary: "Correct the highlighted fields and resend the request." }
|
|
3681
|
+
};
|
|
3682
|
+
throw new AppError(diagnostic, 2 /* Usage */);
|
|
3683
|
+
}
|
|
3684
|
+
var ConnectionInputSchema = z.object({
|
|
3685
|
+
dsn: z.string().optional(),
|
|
3686
|
+
host: z.string().optional(),
|
|
3687
|
+
port: z.number().int().positive().optional(),
|
|
3688
|
+
database: z.string().optional(),
|
|
3689
|
+
user: z.string().optional(),
|
|
3690
|
+
password: z.string().optional(),
|
|
3691
|
+
sslmode: z.enum(["disable", "prefer", "require", "verify-ca", "verify-full"]).optional(),
|
|
3692
|
+
sslrootcert: z.string().optional(),
|
|
3693
|
+
connectTimeoutMs: z.number().int().positive().optional()
|
|
3694
|
+
});
|
|
3695
|
+
var AnalyzeBodySchema = z.object({
|
|
3696
|
+
plan: z.string().min(1, "a plan (EXPLAIN FORMAT JSON) is required"),
|
|
3697
|
+
sql: z.string().optional(),
|
|
3698
|
+
statement: z.number().int().min(1).optional(),
|
|
3699
|
+
redact: z.boolean().optional()
|
|
3700
|
+
});
|
|
3701
|
+
var ExplainFlagsSchema = z.object({
|
|
3702
|
+
analyze: z.boolean(),
|
|
3703
|
+
buffers: z.boolean(),
|
|
3704
|
+
verbose: z.boolean(),
|
|
3705
|
+
settings: z.boolean(),
|
|
3706
|
+
wal: z.boolean(),
|
|
3707
|
+
timing: z.boolean(),
|
|
3708
|
+
costs: z.boolean(),
|
|
3709
|
+
summary: z.boolean(),
|
|
3710
|
+
genericPlan: z.boolean(),
|
|
3711
|
+
compat: z.boolean()
|
|
3712
|
+
}).partial();
|
|
3713
|
+
var RunBodySchema = z.object({
|
|
3714
|
+
connection: ConnectionInputSchema.optional(),
|
|
3715
|
+
connectionId: z.string().optional(),
|
|
3716
|
+
sql: z.string().min(1, "SQL is required"),
|
|
3717
|
+
statement: z.number().int().min(1).optional(),
|
|
3718
|
+
params: z.array(z.string()).optional(),
|
|
3719
|
+
flags: ExplainFlagsSchema.optional(),
|
|
3720
|
+
redact: z.boolean().optional(),
|
|
3721
|
+
statementTimeoutMs: z.number().int().positive().optional(),
|
|
3722
|
+
lockTimeoutMs: z.number().int().positive().optional(),
|
|
3723
|
+
force: z.boolean().optional()
|
|
3724
|
+
}).refine((b) => b.connection || b.connectionId, {
|
|
3725
|
+
message: "provide a connection or a connectionId",
|
|
3726
|
+
path: ["connection"]
|
|
3727
|
+
});
|
|
3728
|
+
var AnalyzeSqlBodySchema = z.object({
|
|
3729
|
+
connection: ConnectionInputSchema.optional(),
|
|
3730
|
+
connectionId: z.string().optional(),
|
|
3731
|
+
sql: z.string().min(1, "SQL is required"),
|
|
3732
|
+
redact: z.boolean().optional()
|
|
3733
|
+
}).refine((b) => b.connection || b.connectionId, {
|
|
3734
|
+
message: "provide a connection or connectionId"
|
|
3735
|
+
});
|
|
3736
|
+
var CatalogBodySchema = z.object({
|
|
3737
|
+
connection: ConnectionInputSchema.optional(),
|
|
3738
|
+
connectionId: z.string().optional()
|
|
3739
|
+
}).refine((b) => b.connection || b.connectionId, {
|
|
3740
|
+
message: "provide a connection or connectionId"
|
|
3741
|
+
});
|
|
3742
|
+
var LiveLocksBodySchema = z.object({
|
|
3743
|
+
connection: ConnectionInputSchema.optional(),
|
|
3744
|
+
connectionId: z.string().optional()
|
|
3745
|
+
}).refine((b) => b.connection || b.connectionId, {
|
|
3746
|
+
message: "provide a connection or connectionId"
|
|
3747
|
+
});
|
|
3748
|
+
var SchemaBodySchema = z.object({
|
|
3749
|
+
connection: ConnectionInputSchema.optional(),
|
|
3750
|
+
connectionId: z.string().optional(),
|
|
3751
|
+
relations: z.array(z.string()).min(1)
|
|
3752
|
+
}).refine((b) => b.connection || b.connectionId, {
|
|
3753
|
+
message: "provide a connection or connectionId"
|
|
3754
|
+
});
|
|
3755
|
+
var ConnectionCreateSchema = z.object({
|
|
3756
|
+
name: z.string().min(1, "a name is required"),
|
|
3757
|
+
dsn: z.string().optional(),
|
|
3758
|
+
host: z.string().optional(),
|
|
3759
|
+
port: z.number().int().positive().optional(),
|
|
3760
|
+
database: z.string().optional(),
|
|
3761
|
+
user: z.string().optional(),
|
|
3762
|
+
password: z.string().optional(),
|
|
3763
|
+
sslmode: z.enum(["disable", "prefer", "require", "verify-ca", "verify-full"]).optional(),
|
|
3764
|
+
sslrootcert: z.string().optional()
|
|
3765
|
+
});
|
|
3766
|
+
var SettingsBodySchema = z.object({
|
|
3767
|
+
thresholds: z.record(z.string(), z.number()).optional(),
|
|
3768
|
+
rules: z.record(
|
|
3769
|
+
z.string(),
|
|
3770
|
+
z.object({
|
|
3771
|
+
enabled: z.boolean().optional(),
|
|
3772
|
+
severity: z.enum(["error", "warn", "info"]).optional()
|
|
3773
|
+
})
|
|
3774
|
+
).optional()
|
|
3775
|
+
});
|
|
3776
|
+
var ExportBodySchema = z.object({
|
|
3777
|
+
runId: z.string().optional(),
|
|
3778
|
+
plan: z.string().optional(),
|
|
3779
|
+
format: z.enum(["markdown", "html", "text", "json"]),
|
|
3780
|
+
sql: z.string().optional(),
|
|
3781
|
+
redact: z.boolean().optional()
|
|
3782
|
+
}).refine((b) => b.runId || b.plan, { message: "provide runId or plan" });
|
|
3783
|
+
var RunPatchSchema = z.object({
|
|
3784
|
+
starred: z.boolean().optional(),
|
|
3785
|
+
label: z.string().nullable().optional(),
|
|
3786
|
+
baseline: z.boolean().optional()
|
|
3787
|
+
});
|
|
3788
|
+
var DiffBodySchema = z.object({
|
|
3789
|
+
beforePlan: z.string().optional(),
|
|
3790
|
+
afterPlan: z.string().optional(),
|
|
3791
|
+
beforeId: z.string().optional(),
|
|
3792
|
+
afterId: z.string().optional(),
|
|
3793
|
+
redact: z.boolean().optional()
|
|
3794
|
+
}).refine((b) => b.beforePlan && b.afterPlan || b.beforeId && b.afterId, {
|
|
3795
|
+
message: "provide beforePlan+afterPlan or beforeId+afterId"
|
|
3796
|
+
});
|
|
3797
|
+
|
|
3798
|
+
// src/server/routes/index.ts
|
|
3799
|
+
function apiRoutes(store, config) {
|
|
3800
|
+
const api = new Hono();
|
|
3801
|
+
api.get(
|
|
3802
|
+
"/api/meta",
|
|
3803
|
+
(c) => c.json({
|
|
3804
|
+
app: "pgexplain",
|
|
3805
|
+
version: package_default.version,
|
|
3806
|
+
node: process.version,
|
|
3807
|
+
formats: FORMATS,
|
|
3808
|
+
defaults: { thresholds: DEFAULT_THRESHOLDS }
|
|
3809
|
+
})
|
|
3810
|
+
);
|
|
3811
|
+
api.get("/api/health", (c) => c.json({ ok: true }));
|
|
3812
|
+
api.get("/api/settings", (c) => c.json(config.current));
|
|
3813
|
+
api.put("/api/settings", async (c) => {
|
|
3814
|
+
const body = validate(SettingsBodySchema, await c.req.json().catch(() => ({})));
|
|
3815
|
+
config.current = await writeStudioConfig(body);
|
|
3816
|
+
return c.json(config.current);
|
|
3817
|
+
});
|
|
3818
|
+
api.post("/api/analyze", async (c) => {
|
|
3819
|
+
const body = validate(AnalyzeBodySchema, await c.req.json().catch(() => ({})));
|
|
3820
|
+
const result = analyze(body.plan, {
|
|
3821
|
+
statement: body.statement,
|
|
3822
|
+
redact: body.redact,
|
|
3823
|
+
sql: body.sql,
|
|
3824
|
+
config: config.current
|
|
3825
|
+
});
|
|
3826
|
+
const report = buildReport(result);
|
|
3827
|
+
const run = store.insertRun(
|
|
3828
|
+
saveFields("analyze", body.sql ?? null, null, result, report, body.plan)
|
|
3829
|
+
);
|
|
3830
|
+
return c.json({ ...report, runId: run.id });
|
|
3831
|
+
});
|
|
3832
|
+
api.post("/api/run", async (c) => {
|
|
3833
|
+
const body = validate(RunBodySchema, await c.req.json().catch(() => ({})));
|
|
3834
|
+
const { connection, connectionId } = resolveConnection(
|
|
3835
|
+
store,
|
|
3836
|
+
body.connection,
|
|
3837
|
+
body.connectionId
|
|
3838
|
+
);
|
|
3839
|
+
const statement = pickStatement(splitStatements(body.sql), body.statement);
|
|
3840
|
+
const flags = { ...DEFAULT_EXPLAIN_FLAGS, ...body.flags ?? {} };
|
|
3841
|
+
if (flags.analyze && !flags.genericPlan && !isReadOnlyStatement(statement) && !body.force) {
|
|
3842
|
+
const verb = statement.trim().split(/\s+/)[0]?.toUpperCase() ?? "statement";
|
|
3843
|
+
throw opError("PGX_NON_SELECT_REFUSED", {
|
|
3844
|
+
detail: `Refusing to ANALYZE a non-SELECT (${verb}) \u2014 it would modify data.`
|
|
3845
|
+
});
|
|
3846
|
+
}
|
|
3847
|
+
const exec = await runExplain({
|
|
3848
|
+
connection,
|
|
3849
|
+
statement,
|
|
3850
|
+
params: body.params,
|
|
3851
|
+
flags,
|
|
3852
|
+
statementTimeoutMs: body.statementTimeoutMs ?? 3e4,
|
|
3853
|
+
lockTimeoutMs: body.lockTimeoutMs ?? 5e3,
|
|
3854
|
+
forceWrite: !!body.force,
|
|
3855
|
+
rollback: true
|
|
3856
|
+
});
|
|
3857
|
+
const result = analyze(exec.json, {
|
|
3858
|
+
redact: body.redact,
|
|
3859
|
+
sql: statement,
|
|
3860
|
+
config: config.current
|
|
3861
|
+
});
|
|
3862
|
+
await checkStaleStats(connection, result, config.current);
|
|
3863
|
+
const report = {
|
|
3864
|
+
...buildReport(result),
|
|
3865
|
+
server: { major: exec.caps.major, omitted: exec.omitted }
|
|
3866
|
+
};
|
|
3867
|
+
const run = store.insertRun(
|
|
3868
|
+
saveFields("run", statement, connectionId, result, report, exec.json)
|
|
3869
|
+
);
|
|
3870
|
+
return c.json({ ...report, runId: run.id });
|
|
3871
|
+
});
|
|
3872
|
+
api.post("/api/diff", async (c) => {
|
|
3873
|
+
const body = validate(DiffBodySchema, await c.req.json().catch(() => ({})));
|
|
3874
|
+
const beforePlan = body.beforePlan ?? planTextOf(store, body.beforeId);
|
|
3875
|
+
const afterPlan = body.afterPlan ?? planTextOf(store, body.afterId);
|
|
3876
|
+
const before = analyze(beforePlan, { redact: body.redact });
|
|
3877
|
+
const after = analyze(afterPlan, { redact: body.redact });
|
|
3878
|
+
return c.json({
|
|
3879
|
+
...diffAnalyses(before, after),
|
|
3880
|
+
beforePlan: serializeNode(before.tree.root),
|
|
3881
|
+
afterPlan: serializeNode(after.tree.root)
|
|
3882
|
+
});
|
|
3883
|
+
});
|
|
3884
|
+
api.post("/api/export", async (c) => {
|
|
3885
|
+
const body = validate(ExportBodySchema, await c.req.json().catch(() => ({})));
|
|
3886
|
+
const planText = body.plan ?? planTextOf(store, body.runId);
|
|
3887
|
+
const result = analyze(planText, { redact: body.redact, sql: body.sql });
|
|
3888
|
+
const content = render(result, { format: body.format });
|
|
3889
|
+
const types = {
|
|
3890
|
+
markdown: "text/markdown",
|
|
3891
|
+
html: "text/html",
|
|
3892
|
+
text: "text/plain",
|
|
3893
|
+
json: "application/json"
|
|
3894
|
+
};
|
|
3895
|
+
return c.body(content, 200, { "Content-Type": `${types[body.format]}; charset=utf-8` });
|
|
3896
|
+
});
|
|
3897
|
+
api.post("/api/catalog", async (c) => {
|
|
3898
|
+
const body = validate(CatalogBodySchema, await c.req.json().catch(() => ({})));
|
|
3899
|
+
const { connection } = resolveConnection(store, body.connection, body.connectionId);
|
|
3900
|
+
return c.json({ tables: await catalog(connection) });
|
|
3901
|
+
});
|
|
3902
|
+
api.post("/api/schema", async (c) => {
|
|
3903
|
+
const body = validate(SchemaBodySchema, await c.req.json().catch(() => ({})));
|
|
3904
|
+
const { connection } = resolveConnection(store, body.connection, body.connectionId);
|
|
3905
|
+
return c.json({ relations: await relationStats(connection, body.relations) });
|
|
3906
|
+
});
|
|
3907
|
+
api.post("/api/analyze-sql", async (c) => {
|
|
3908
|
+
const body = validate(AnalyzeSqlBodySchema, await c.req.json().catch(() => ({})));
|
|
3909
|
+
const { connection } = resolveConnection(store, body.connection, body.connectionId);
|
|
3910
|
+
const analysis = await analyzeScript(connection, body.sql, {
|
|
3911
|
+
config: config.current,
|
|
3912
|
+
redact: body.redact,
|
|
3913
|
+
statementTimeoutMs: 3e4,
|
|
3914
|
+
lockTimeoutMs: 5e3
|
|
3915
|
+
});
|
|
3916
|
+
return c.json({
|
|
3917
|
+
executed: false,
|
|
3918
|
+
serverMajor: analysis.serverMajor ?? null,
|
|
3919
|
+
units: analysis.units.map((u) => ({
|
|
3920
|
+
label: u.label,
|
|
3921
|
+
status: u.status,
|
|
3922
|
+
loopNote: u.loopNote ?? null,
|
|
3923
|
+
report: u.report ?? null,
|
|
3924
|
+
reason: u.reason ?? null,
|
|
3925
|
+
errorCode: u.errorCode ?? null
|
|
3926
|
+
}))
|
|
3927
|
+
});
|
|
3928
|
+
});
|
|
3929
|
+
api.post("/api/locks/live", async (c) => {
|
|
3930
|
+
const body = validate(LiveLocksBodySchema, await c.req.json().catch(() => ({})));
|
|
3931
|
+
const { connection } = resolveConnection(store, body.connection, body.connectionId);
|
|
3932
|
+
return c.json(await liveLocks(connection, Date.now()));
|
|
3933
|
+
});
|
|
3934
|
+
api.get("/api/history", (c) => c.json({ runs: store.listRuns() }));
|
|
3935
|
+
api.get("/api/history/:id", (c) => {
|
|
3936
|
+
const run = store.getRun(c.req.param("id"));
|
|
3937
|
+
return run ? c.json(run) : c.json({ error: { code: "PGX_NOT_FOUND", title: "No such run" } }, 404);
|
|
3938
|
+
});
|
|
3939
|
+
api.delete(
|
|
3940
|
+
"/api/history/:id",
|
|
3941
|
+
(c) => store.deleteRun(c.req.param("id")) ? c.json({ ok: true }) : c.json({ error: { code: "PGX_NOT_FOUND" } }, 404)
|
|
3942
|
+
);
|
|
3943
|
+
api.patch("/api/history/:id", async (c) => {
|
|
3944
|
+
const patch = validate(RunPatchSchema, await c.req.json().catch(() => ({})));
|
|
3945
|
+
const run = store.updateRun(c.req.param("id"), patch);
|
|
3946
|
+
return run ? c.json(run) : c.json({ error: { code: "PGX_NOT_FOUND" } }, 404);
|
|
3947
|
+
});
|
|
3948
|
+
api.get("/api/connections", (c) => c.json({ connections: store.listConnections() }));
|
|
3949
|
+
api.post("/api/connections", async (c) => {
|
|
3950
|
+
const input = validate(ConnectionCreateSchema, await c.req.json().catch(() => ({})));
|
|
3951
|
+
return c.json(store.createConnection(input), 201);
|
|
3952
|
+
});
|
|
3953
|
+
api.put("/api/connections/:id", async (c) => {
|
|
3954
|
+
const input = validate(ConnectionCreateSchema, await c.req.json().catch(() => ({})));
|
|
3955
|
+
const updated = store.updateConnection(c.req.param("id"), input);
|
|
3956
|
+
return updated ? c.json(updated) : c.json({ error: { code: "PGX_NOT_FOUND" } }, 404);
|
|
3957
|
+
});
|
|
3958
|
+
api.delete(
|
|
3959
|
+
"/api/connections/:id",
|
|
3960
|
+
(c) => store.deleteConnection(c.req.param("id")) ? c.json({ ok: true }) : c.json({ error: { code: "PGX_NOT_FOUND" } }, 404)
|
|
3961
|
+
);
|
|
3962
|
+
return api;
|
|
3963
|
+
}
|
|
3964
|
+
function resolveConnection(store, inline, connectionId) {
|
|
3965
|
+
if (inline) return { connection: toConnectionOptions(inline), connectionId: null };
|
|
3966
|
+
if (connectionId) {
|
|
3967
|
+
const saved = store.getConnection(connectionId);
|
|
3968
|
+
if (!saved)
|
|
3969
|
+
throw opError("PGX_EMPTY_INPUT", { detail: `No saved connection ${connectionId}.` });
|
|
3970
|
+
return { connection: toConnectionOptions(saved), connectionId };
|
|
3971
|
+
}
|
|
3972
|
+
throw opError("PGX_EMPTY_INPUT", { detail: "Provide a connection or a connectionId." });
|
|
3973
|
+
}
|
|
3974
|
+
function toConnectionOptions(input) {
|
|
3975
|
+
const c = {
|
|
3976
|
+
connectTimeoutMs: "connectTimeoutMs" in input && input.connectTimeoutMs || 1e4
|
|
3977
|
+
};
|
|
3978
|
+
if (input.dsn) c.dsn = input.dsn;
|
|
3979
|
+
if (input.host) c.host = input.host;
|
|
3980
|
+
if (input.port) c.port = input.port;
|
|
3981
|
+
if (input.database) c.database = input.database;
|
|
3982
|
+
if (input.user) c.user = input.user;
|
|
3983
|
+
if ("password" in input && input.password) c.password = input.password;
|
|
3984
|
+
if (input.sslmode) c.sslmode = input.sslmode;
|
|
3985
|
+
if (input.sslrootcert) c.sslrootcert = input.sslrootcert;
|
|
3986
|
+
return c;
|
|
3987
|
+
}
|
|
3988
|
+
function saveFields(kind, sql, connectionId, result, report, planText) {
|
|
3989
|
+
const counts = { error: 0, warn: 0, info: 0 };
|
|
3990
|
+
for (const d of result.diagnostics) counts[d.severity]++;
|
|
3991
|
+
return {
|
|
3992
|
+
kind,
|
|
3993
|
+
sql,
|
|
3994
|
+
connectionId,
|
|
3995
|
+
planText,
|
|
3996
|
+
verdict: result.verdict,
|
|
3997
|
+
worstSeverity: result.worstSeverity,
|
|
3998
|
+
execMs: executionMs(result.tree) ?? null,
|
|
3999
|
+
counts,
|
|
4000
|
+
report
|
|
4001
|
+
};
|
|
4002
|
+
}
|
|
4003
|
+
function planTextOf(store, id) {
|
|
4004
|
+
if (!id)
|
|
4005
|
+
throw opError("PGX_EMPTY_INPUT", {
|
|
4006
|
+
detail: "Provide beforePlan/afterPlan or beforeId/afterId."
|
|
4007
|
+
});
|
|
4008
|
+
const run = store.getRun(id);
|
|
4009
|
+
if (!run?.planText) {
|
|
4010
|
+
throw opError("PGX_EMPTY_INPUT", { detail: `Run ${id} has no stored plan to diff.` });
|
|
4011
|
+
}
|
|
4012
|
+
return run.planText;
|
|
4013
|
+
}
|
|
4014
|
+
function pickStatement(statements, index) {
|
|
4015
|
+
if (statements.length === 0)
|
|
4016
|
+
throw opError("PGX_EMPTY_INPUT", { detail: "No SQL statement found." });
|
|
4017
|
+
if (index !== void 0) {
|
|
4018
|
+
const stmt = statements[index - 1];
|
|
4019
|
+
if (!stmt) {
|
|
4020
|
+
throw opError("PGX_MULTIPLE_STATEMENTS", {
|
|
4021
|
+
detail: `statement ${index} is out of range; found ${statements.length}.`
|
|
4022
|
+
});
|
|
4023
|
+
}
|
|
4024
|
+
return stmt;
|
|
4025
|
+
}
|
|
4026
|
+
if (statements.length > 1) {
|
|
4027
|
+
throw opError("PGX_MULTIPLE_STATEMENTS", {
|
|
4028
|
+
detail: `Found ${statements.length} statements; pass a 1-based "statement" index.`
|
|
4029
|
+
});
|
|
4030
|
+
}
|
|
4031
|
+
return statements[0];
|
|
4032
|
+
}
|
|
4033
|
+
|
|
4034
|
+
// src/server/app.ts
|
|
4035
|
+
var MIME = {
|
|
4036
|
+
".html": "text/html; charset=utf-8",
|
|
4037
|
+
".js": "text/javascript; charset=utf-8",
|
|
4038
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
4039
|
+
".css": "text/css; charset=utf-8",
|
|
4040
|
+
".json": "application/json; charset=utf-8",
|
|
4041
|
+
".svg": "image/svg+xml",
|
|
4042
|
+
".png": "image/png",
|
|
4043
|
+
".ico": "image/x-icon",
|
|
4044
|
+
".woff2": "font/woff2",
|
|
4045
|
+
".woff": "font/woff",
|
|
4046
|
+
".map": "application/json"
|
|
4047
|
+
};
|
|
4048
|
+
function createApp(opts) {
|
|
4049
|
+
const app = new Hono();
|
|
4050
|
+
const webRoot = resolve(opts.webRoot);
|
|
4051
|
+
app.route("/", apiRoutes(opts.store, { current: opts.config }));
|
|
4052
|
+
app.onError((err, c) => {
|
|
4053
|
+
if (err instanceof AppError) {
|
|
4054
|
+
const status = httpStatusFor(err.exitCode);
|
|
4055
|
+
return c.json({ error: scrubDiagnostic(err.diagnostic) }, status);
|
|
4056
|
+
}
|
|
4057
|
+
return c.json(
|
|
4058
|
+
{
|
|
4059
|
+
error: {
|
|
4060
|
+
code: "PGX_INTERNAL",
|
|
4061
|
+
severity: "error",
|
|
4062
|
+
title: "pgexplain hit an unexpected error",
|
|
4063
|
+
detail: scrubCredentials(err instanceof Error ? err.message : String(err)),
|
|
4064
|
+
remediation: {
|
|
4065
|
+
summary: "Retry; if it recurs, file an issue with the steps to reproduce."
|
|
4066
|
+
}
|
|
4067
|
+
}
|
|
4068
|
+
},
|
|
4069
|
+
500
|
|
4070
|
+
);
|
|
4071
|
+
});
|
|
4072
|
+
app.notFound((c) => {
|
|
4073
|
+
if (c.req.path.startsWith("/api")) {
|
|
4074
|
+
return c.json(
|
|
4075
|
+
{ error: { code: "PGX_NOT_FOUND", title: "No such API route", detail: c.req.path } },
|
|
4076
|
+
404
|
|
4077
|
+
);
|
|
4078
|
+
}
|
|
4079
|
+
return serveSpa(c, webRoot);
|
|
4080
|
+
});
|
|
4081
|
+
app.get("*", async (c) => {
|
|
4082
|
+
if (c.req.path.startsWith("/api")) return c.notFound();
|
|
4083
|
+
const file = await resolveStatic(webRoot, c.req.path);
|
|
4084
|
+
if (file) {
|
|
4085
|
+
return c.body(toBytes(file.body), 200, {
|
|
4086
|
+
"Content-Type": MIME[file.ext] ?? "application/octet-stream"
|
|
4087
|
+
});
|
|
4088
|
+
}
|
|
4089
|
+
return serveSpa(c, webRoot);
|
|
4090
|
+
});
|
|
4091
|
+
return app;
|
|
4092
|
+
}
|
|
4093
|
+
async function serveSpa(c, webRoot) {
|
|
4094
|
+
const index = await readFileSafe(join(webRoot, "index.html"));
|
|
4095
|
+
if (index) return c.body(toBytes(index), 200, { "Content-Type": "text/html; charset=utf-8" });
|
|
4096
|
+
return c.html(
|
|
4097
|
+
`<!doctype html><meta charset="utf-8"><title>pgexplain studio</title>
|
|
4098
|
+
<body style="font:15px system-ui;padding:3rem;max-width:40rem;margin:auto">
|
|
4099
|
+
<h1>UI not built</h1>
|
|
4100
|
+
<p>The studio UI bundle is missing at <code>${webRoot}</code>.</p>
|
|
4101
|
+
<p>Build it with <code>pnpm run build:web</code>, then restart <code>pg-explain studio</code>.</p>
|
|
4102
|
+
</body>`,
|
|
4103
|
+
200
|
|
4104
|
+
);
|
|
4105
|
+
}
|
|
4106
|
+
async function resolveStatic(webRoot, urlPath) {
|
|
4107
|
+
const rel = normalize(decodeURIComponent(urlPath)).replace(/^(\.\.[/\\])+/, "");
|
|
4108
|
+
const full = join(webRoot, rel);
|
|
4109
|
+
if (!full.startsWith(webRoot)) return null;
|
|
4110
|
+
try {
|
|
4111
|
+
if (!(await stat(full)).isFile()) return null;
|
|
4112
|
+
} catch {
|
|
4113
|
+
return null;
|
|
4114
|
+
}
|
|
4115
|
+
const body = await readFileSafe(full);
|
|
4116
|
+
return body ? { body, ext: extname(full) } : null;
|
|
4117
|
+
}
|
|
4118
|
+
function toBytes(u8) {
|
|
4119
|
+
return u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
|
|
4120
|
+
}
|
|
4121
|
+
async function readFileSafe(path) {
|
|
4122
|
+
try {
|
|
4123
|
+
return await readFile(path);
|
|
4124
|
+
} catch {
|
|
4125
|
+
return null;
|
|
4126
|
+
}
|
|
4127
|
+
}
|
|
4128
|
+
function httpStatusFor(exitCode) {
|
|
4129
|
+
switch (exitCode) {
|
|
4130
|
+
case 2:
|
|
4131
|
+
return 400;
|
|
4132
|
+
case 3:
|
|
4133
|
+
case 4:
|
|
4134
|
+
return 422;
|
|
4135
|
+
case 5:
|
|
4136
|
+
return 502;
|
|
4137
|
+
default:
|
|
4138
|
+
return 500;
|
|
4139
|
+
}
|
|
4140
|
+
}
|
|
4141
|
+
function scrubDiagnostic(d) {
|
|
4142
|
+
const json = JSON.stringify(d);
|
|
4143
|
+
return JSON.parse(scrubCredentials(json));
|
|
4144
|
+
}
|
|
4145
|
+
|
|
4146
|
+
// src/server/start.ts
|
|
4147
|
+
async function startStudio(opts) {
|
|
4148
|
+
const webRoot = opts.webRoot ?? fileURLToPath(new URL("./web", import.meta.url));
|
|
4149
|
+
const app = createApp({ webRoot, store: openStore(), config: await readStudioConfig() });
|
|
4150
|
+
return new Promise((resolvePromise) => {
|
|
4151
|
+
const server = serve({ fetch: app.fetch, hostname: opts.host, port: opts.port }, (info) => {
|
|
4152
|
+
resolvePromise({
|
|
4153
|
+
url: `http://${displayHost(opts.host)}:${info.port}`,
|
|
4154
|
+
port: info.port,
|
|
4155
|
+
close: () => new Promise((res) => server.close(() => res()))
|
|
4156
|
+
});
|
|
4157
|
+
});
|
|
4158
|
+
});
|
|
4159
|
+
}
|
|
4160
|
+
function displayHost(host) {
|
|
4161
|
+
return host === "0.0.0.0" || host === "::" ? "localhost" : host;
|
|
4162
|
+
}
|
|
4163
|
+
|
|
4164
|
+
export { startStudio };
|
|
4165
|
+
//# sourceMappingURL=server.js.map
|
|
4166
|
+
//# sourceMappingURL=server.js.map
|