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/cli.js
CHANGED
|
@@ -4,10 +4,12 @@ import { readFile, writeFile, stat, readdir } from 'fs/promises';
|
|
|
4
4
|
import { join } from 'path';
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
import pc from 'picocolors';
|
|
7
|
+
import { tmpdir } from 'os';
|
|
8
|
+
import { spawn } from 'child_process';
|
|
7
9
|
|
|
8
10
|
// package.json
|
|
9
11
|
var package_default = {
|
|
10
|
-
version: "0.
|
|
12
|
+
version: "0.3.0"};
|
|
11
13
|
|
|
12
14
|
// src/diagnostics/diagnostic.ts
|
|
13
15
|
var AppError = class extends Error {
|
|
@@ -21,6 +23,9 @@ var AppError = class extends Error {
|
|
|
21
23
|
if (cause !== void 0) this.cause = cause;
|
|
22
24
|
}
|
|
23
25
|
};
|
|
26
|
+
function finding(code, severity, parts) {
|
|
27
|
+
return { code, domain: "plan", severity, ...parts };
|
|
28
|
+
}
|
|
24
29
|
var SEVERITY_RANK = { error: 0, warn: 1, info: 2 };
|
|
25
30
|
function bySeverity(a, b) {
|
|
26
31
|
return SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
|
|
@@ -428,7 +433,9 @@ var DEFAULT_THRESHOLDS = {
|
|
|
428
433
|
correlatedLoops: 1e3,
|
|
429
434
|
jitPct: 25,
|
|
430
435
|
triggerPct: 10,
|
|
431
|
-
lowCacheHitRatio: 0.9
|
|
436
|
+
lowCacheHitRatio: 0.9,
|
|
437
|
+
limitDiscardRows: 1e4,
|
|
438
|
+
staleStatsModRatio: 0.2
|
|
432
439
|
};
|
|
433
440
|
var DEFAULT_CONFIG = {
|
|
434
441
|
thresholds: { ...DEFAULT_THRESHOLDS },
|
|
@@ -482,6 +489,308 @@ async function loadConfig(explicitPath, cwd = process.cwd()) {
|
|
|
482
489
|
}
|
|
483
490
|
return { ...DEFAULT_CONFIG, thresholds: { ...DEFAULT_THRESHOLDS }, rules: {} };
|
|
484
491
|
}
|
|
492
|
+
|
|
493
|
+
// src/core/parse-text.ts
|
|
494
|
+
var cap = (w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase();
|
|
495
|
+
var numeric = (v) => {
|
|
496
|
+
const t = v.trim();
|
|
497
|
+
return /^-?\d+(\.\d+)?$/.test(t) ? Number(t) : t;
|
|
498
|
+
};
|
|
499
|
+
function splitList(s) {
|
|
500
|
+
const out = [];
|
|
501
|
+
let depth = 0;
|
|
502
|
+
let cur = "";
|
|
503
|
+
for (const ch of s) {
|
|
504
|
+
if (ch === "(") depth++;
|
|
505
|
+
else if (ch === ")") depth--;
|
|
506
|
+
if (ch === "," && depth === 0) {
|
|
507
|
+
if (cur.trim()) out.push(cur.trim());
|
|
508
|
+
cur = "";
|
|
509
|
+
} else {
|
|
510
|
+
cur += ch;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (cur.trim()) out.push(cur.trim());
|
|
514
|
+
return out;
|
|
515
|
+
}
|
|
516
|
+
function splitIntoLines(text) {
|
|
517
|
+
const out = [];
|
|
518
|
+
const lines = text.split(/\r?\n/);
|
|
519
|
+
const count = (s, re) => (s.match(re) || []).length;
|
|
520
|
+
const closingFirst = (s) => {
|
|
521
|
+
const c = s.indexOf(")");
|
|
522
|
+
const o = s.indexOf("(");
|
|
523
|
+
return c !== -1 && c < o;
|
|
524
|
+
};
|
|
525
|
+
const sameIndent = (a, b) => a.search(/\S/) === b.search(/\S/);
|
|
526
|
+
for (const line of lines) {
|
|
527
|
+
const prev = out[out.length - 1];
|
|
528
|
+
if (prev && count(prev, /\)/g) !== count(prev, /\(/g)) {
|
|
529
|
+
out[out.length - 1] += line;
|
|
530
|
+
} else if (/^(?:Total\s+runtime|Planning(\s+time)?|Execution\s+time|Time|Filter|Output|JIT|Trigger|Settings|Serialization)/i.test(
|
|
531
|
+
line
|
|
532
|
+
)) {
|
|
533
|
+
out.push(line);
|
|
534
|
+
} else if (/^\S/.test(line) || /^\s*\(/.test(line) || closingFirst(line)) {
|
|
535
|
+
if (prev) out[out.length - 1] += line;
|
|
536
|
+
else out.push(line);
|
|
537
|
+
} else if (prev && /,\s*$/.test(prev) && !sameIndent(prev, line) && !/^\s*->/i.test(line)) {
|
|
538
|
+
out[out.length - 1] += line;
|
|
539
|
+
} else {
|
|
540
|
+
out.push(line);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return out;
|
|
544
|
+
}
|
|
545
|
+
var estimation = String.raw`\(cost=(\d+\.\d+)\.\.(\d+\.\d+)\s+rows=(\d+)\s+width=(\d+)\)`;
|
|
546
|
+
var actual = String.raw`(?:actual(?:\stime=(\d+\.\d+)\.\.(\d+\.\d+))?\srows=(\d+(?:\.\d+)?)\sloops=(\d+)|(never\s+executed))`;
|
|
547
|
+
var nodeRe = new RegExp(
|
|
548
|
+
String.raw`^(\s*->\s*|\s*)(Finalize|Simple|Partial)*\s*([^\r\n\t\f\v(]*?)\s*` + String.raw`(?:(?:${estimation}\s+\(${actual}\))|(?:${estimation})|(?:\(${actual}\)))\s*$`
|
|
549
|
+
);
|
|
550
|
+
var subRe = /^((?:Sub|Init)Plan)\s*(?:\d+\s*)?(?:\(returns.*\))?\s*$/;
|
|
551
|
+
var cteRe = /^CTE\s+(\S+)\s*$/;
|
|
552
|
+
var workerRe = /^Worker\s+(\d+):\s+(?:actual(?:\stime=(\d+\.\d+)\.\.(\d+\.\d+))?\srows=(\d+(?:\.\d+)?)\sloops=(\d+)|never\s+executed)(.*)$/;
|
|
553
|
+
var triggerRe = /^Trigger\s+(.*):\s+time=(\d+\.\d+)\s+calls=(\d+)\s*$/;
|
|
554
|
+
var headerRe = /^(QUERY PLAN|-{2,}|#|\(\d+ rows?\))/;
|
|
555
|
+
function splitNodeType(text) {
|
|
556
|
+
let s = text.trim();
|
|
557
|
+
let indexName;
|
|
558
|
+
let relationName;
|
|
559
|
+
let schema;
|
|
560
|
+
let alias;
|
|
561
|
+
const using = s.match(/\susing (\S+)/);
|
|
562
|
+
if (using?.[1]) {
|
|
563
|
+
indexName = using[1];
|
|
564
|
+
s = s.replace(using[0] ?? "", "");
|
|
565
|
+
}
|
|
566
|
+
const on = s.match(/\son (\S+?)(?:\s+(\S+))?\s*$/);
|
|
567
|
+
if (on?.[1]) {
|
|
568
|
+
let rel = on[1];
|
|
569
|
+
alias = on[2];
|
|
570
|
+
const dot = rel.lastIndexOf(".");
|
|
571
|
+
if (dot !== -1) {
|
|
572
|
+
schema = rel.slice(0, dot);
|
|
573
|
+
rel = rel.slice(dot + 1);
|
|
574
|
+
}
|
|
575
|
+
relationName = rel;
|
|
576
|
+
s = s.slice(0, on.index).trim();
|
|
577
|
+
}
|
|
578
|
+
const nodeType = s.replace(/^Parallel\s+/, "").trim();
|
|
579
|
+
if (nodeType === "Bitmap Index Scan" && relationName && !indexName) {
|
|
580
|
+
indexName = relationName;
|
|
581
|
+
relationName = void 0;
|
|
582
|
+
schema = void 0;
|
|
583
|
+
alias = void 0;
|
|
584
|
+
}
|
|
585
|
+
const out = { "Node Type": nodeType };
|
|
586
|
+
if (relationName) out["Relation Name"] = relationName;
|
|
587
|
+
if (indexName) out["Index Name"] = indexName;
|
|
588
|
+
if (schema) out.Schema = schema;
|
|
589
|
+
if (alias && alias !== relationName) out.Alias = alias;
|
|
590
|
+
return out;
|
|
591
|
+
}
|
|
592
|
+
function parseSort(text, node) {
|
|
593
|
+
const m = text.match(/^Sort Method:\s+(.*?)\s+(Memory|Disk):\s+(\S+)kB\s*$/);
|
|
594
|
+
if (!m?.[1] || !m[2] || m[3] === void 0) return false;
|
|
595
|
+
node["Sort Method"] = m[1].trim();
|
|
596
|
+
node["Sort Space Type"] = m[2];
|
|
597
|
+
node["Sort Space Used"] = Number(m[3]);
|
|
598
|
+
return true;
|
|
599
|
+
}
|
|
600
|
+
function parseBuffers(text, node) {
|
|
601
|
+
const m = text.match(/^Buffers:\s+(.*)$/);
|
|
602
|
+
if (!m?.[1]) return false;
|
|
603
|
+
for (const group of m[1].split(/,\s+/)) {
|
|
604
|
+
const g = group.match(/^(shared|temp|local)\s+(.*)$/);
|
|
605
|
+
if (!g?.[1] || g[2] === void 0) continue;
|
|
606
|
+
const type = cap(g[1]);
|
|
607
|
+
for (const kv of g[2].trim().split(/\s+/)) {
|
|
608
|
+
const [method, value] = kv.split("=");
|
|
609
|
+
if (method && value !== void 0) node[`${type} ${cap(method)} Blocks`] = Number(value);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return true;
|
|
613
|
+
}
|
|
614
|
+
function parseWal(text, node) {
|
|
615
|
+
const m = text.match(/^WAL:\s+(.*)$/);
|
|
616
|
+
if (!m?.[1]) return false;
|
|
617
|
+
for (const kv of m[1].trim().split(/\s+/)) {
|
|
618
|
+
const [k, value] = kv.split("=");
|
|
619
|
+
if (!k || value === void 0) continue;
|
|
620
|
+
node[`WAL ${k === "fpi" ? "FPI" : cap(k)}`] = Number(value);
|
|
621
|
+
}
|
|
622
|
+
return true;
|
|
623
|
+
}
|
|
624
|
+
function parseIoTimings(text, node) {
|
|
625
|
+
const m = text.match(/^I\/O Timings:\s+(.*)$/);
|
|
626
|
+
if (!m?.[1]) return false;
|
|
627
|
+
const read = m[1].match(/(?:^|\s)read=(\d+(?:\.\d+)?)/);
|
|
628
|
+
const write2 = m[1].match(/(?:^|\s)write=(\d+(?:\.\d+)?)/);
|
|
629
|
+
if (read?.[1]) node["I/O Read Time"] = Number(read[1]);
|
|
630
|
+
if (write2?.[1]) node["I/O Write Time"] = Number(write2[1]);
|
|
631
|
+
return true;
|
|
632
|
+
}
|
|
633
|
+
function parseSettings(text) {
|
|
634
|
+
const out = {};
|
|
635
|
+
for (const pair of splitList(text)) {
|
|
636
|
+
const m = pair.match(/^(\S+)\s*=\s*(.*)$/);
|
|
637
|
+
if (m?.[1] && m[2] !== void 0) out[m[1]] = m[2].replace(/^'|'$/g, "");
|
|
638
|
+
}
|
|
639
|
+
return out;
|
|
640
|
+
}
|
|
641
|
+
var LIST_KEYS = /* @__PURE__ */ new Set(["Output", "Sort Key", "Presorted Key", "Group Key"]);
|
|
642
|
+
function parseTextToStatements(input) {
|
|
643
|
+
const statements = [];
|
|
644
|
+
let stmt = null;
|
|
645
|
+
let stack = [];
|
|
646
|
+
let current = null;
|
|
647
|
+
let jit = null;
|
|
648
|
+
const finish = () => {
|
|
649
|
+
if (stmt?.Plan) statements.push(stmt);
|
|
650
|
+
stmt = null;
|
|
651
|
+
stack = [];
|
|
652
|
+
current = null;
|
|
653
|
+
jit = null;
|
|
654
|
+
};
|
|
655
|
+
for (let raw of splitIntoLines(input)) {
|
|
656
|
+
raw = raw.replace(/"\s*$/, "").replace(/^\s*"/, "").replace(/\t/g, " ");
|
|
657
|
+
const depth = raw.match(/^\s*/)?.[0].length ?? 0;
|
|
658
|
+
const line = raw.slice(depth);
|
|
659
|
+
if (line === "" || headerRe.test(line)) {
|
|
660
|
+
if (line === "" && stmt?.Plan) finish();
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
const nodeM = nodeRe.exec(line);
|
|
664
|
+
const subM = subRe.exec(line);
|
|
665
|
+
const cteM = cteRe.exec(line);
|
|
666
|
+
if (nodeM && !subM && !cteM) {
|
|
667
|
+
if (!stmt) stmt = {};
|
|
668
|
+
jit = null;
|
|
669
|
+
const node = { ...splitNodeType(nodeM[3] ?? "") };
|
|
670
|
+
if (nodeM[2]) node["Partial Mode"] = nodeM[2];
|
|
671
|
+
const startup = nodeM[4] ?? nodeM[13];
|
|
672
|
+
const total = nodeM[5] ?? nodeM[14];
|
|
673
|
+
if (startup && total) {
|
|
674
|
+
node["Startup Cost"] = Number(startup);
|
|
675
|
+
node["Total Cost"] = Number(total);
|
|
676
|
+
node["Plan Rows"] = Number(nodeM[6] ?? nodeM[15]);
|
|
677
|
+
node["Plan Width"] = Number(nodeM[7] ?? nodeM[16]);
|
|
678
|
+
}
|
|
679
|
+
const st = nodeM[8] ?? nodeM[17];
|
|
680
|
+
const tt = nodeM[9] ?? nodeM[18];
|
|
681
|
+
if (st && tt) {
|
|
682
|
+
node["Actual Startup Time"] = Number(st);
|
|
683
|
+
node["Actual Total Time"] = Number(tt);
|
|
684
|
+
}
|
|
685
|
+
const rows = nodeM[10] ?? nodeM[19];
|
|
686
|
+
const loops = nodeM[11] ?? nodeM[20];
|
|
687
|
+
if (rows && loops) {
|
|
688
|
+
node["Actual Rows"] = Number(rows);
|
|
689
|
+
node["Actual Loops"] = Number(loops);
|
|
690
|
+
}
|
|
691
|
+
if (nodeM[12] ?? nodeM[21]) {
|
|
692
|
+
node["Actual Loops"] = 0;
|
|
693
|
+
node["Actual Rows"] = 0;
|
|
694
|
+
}
|
|
695
|
+
stack = stack.filter((f) => f.depth < depth);
|
|
696
|
+
const parent = stack[stack.length - 1];
|
|
697
|
+
if (!parent) {
|
|
698
|
+
stmt.Plan = node;
|
|
699
|
+
} else {
|
|
700
|
+
if (parent.rel) {
|
|
701
|
+
node["Parent Relationship"] = parent.rel;
|
|
702
|
+
if (parent.name) node["Subplan Name"] = parent.name;
|
|
703
|
+
}
|
|
704
|
+
const parentNode = parent.node;
|
|
705
|
+
if (!parentNode.Plans) parentNode.Plans = [];
|
|
706
|
+
parentNode.Plans.push(node);
|
|
707
|
+
}
|
|
708
|
+
stack.push({ depth, node });
|
|
709
|
+
current = node;
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
if (subM || cteM) {
|
|
713
|
+
stack = stack.filter((f) => f.depth < depth);
|
|
714
|
+
const parent = stack[stack.length - 1];
|
|
715
|
+
if (!parent) continue;
|
|
716
|
+
if (cteM?.[1])
|
|
717
|
+
stack.push({ depth, node: parent.node, rel: "InitPlan", name: `CTE ${cteM[1]}` });
|
|
718
|
+
else if (subM?.[1])
|
|
719
|
+
stack.push({
|
|
720
|
+
depth,
|
|
721
|
+
node: parent.node,
|
|
722
|
+
rel: subM[1],
|
|
723
|
+
name: (subM[0] ?? "").trim()
|
|
724
|
+
});
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
const workerM = workerRe.exec(line);
|
|
728
|
+
if (workerM && current) {
|
|
729
|
+
const worker = { "Worker Number": Number(workerM[1]) };
|
|
730
|
+
if (workerM[2] && workerM[3]) {
|
|
731
|
+
worker["Actual Startup Time"] = Number(workerM[2]);
|
|
732
|
+
worker["Actual Total Time"] = Number(workerM[3]);
|
|
733
|
+
}
|
|
734
|
+
if (workerM[4] && workerM[5]) {
|
|
735
|
+
worker["Actual Rows"] = Number(workerM[4]);
|
|
736
|
+
worker["Actual Loops"] = Number(workerM[5]);
|
|
737
|
+
}
|
|
738
|
+
if (!Array.isArray(current.Workers)) current.Workers = [];
|
|
739
|
+
current.Workers.push(worker);
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
const trigM = triggerRe.exec(line);
|
|
743
|
+
if (trigM && stmt) {
|
|
744
|
+
if (!Array.isArray(stmt.Triggers)) stmt.Triggers = [];
|
|
745
|
+
stmt.Triggers.push({
|
|
746
|
+
"Trigger Name": trigM[1],
|
|
747
|
+
Time: Number(trigM[2]),
|
|
748
|
+
Calls: Number(trigM[3])
|
|
749
|
+
});
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
const kv = line.match(/^([^:]+):\s*(.*)$/);
|
|
753
|
+
if (!kv?.[1]) continue;
|
|
754
|
+
const key = kv[1].trim();
|
|
755
|
+
const value = (kv[2] ?? "").trim();
|
|
756
|
+
if (key === "JIT") {
|
|
757
|
+
jit = {};
|
|
758
|
+
if (stmt) stmt.JIT = jit;
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
if (jit) {
|
|
762
|
+
if (key === "Functions") jit.Functions = Number(value);
|
|
763
|
+
else if (key === "Timing") {
|
|
764
|
+
const timing = {};
|
|
765
|
+
for (const part of value.split(/,\s*/)) {
|
|
766
|
+
const t = part.match(/^(\S+)\s+(\d+\.\d+)\s*ms/);
|
|
767
|
+
if (t?.[1]) timing[t[1]] = Number(t[2]);
|
|
768
|
+
}
|
|
769
|
+
jit.Timing = timing;
|
|
770
|
+
}
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
if (key === "Planning Time") {
|
|
774
|
+
if (stmt) stmt["Planning Time"] = parseFloat(value);
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
if (key === "Execution Time" || key === "Total runtime") {
|
|
778
|
+
if (stmt) stmt["Execution Time"] = parseFloat(value);
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
if (key === "Settings") {
|
|
782
|
+
if (stmt) stmt.Settings = parseSettings(value);
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
if (!current) continue;
|
|
786
|
+
if (parseSort(line, current) || parseBuffers(line, current) || parseWal(line, current) || parseIoTimings(line, current)) {
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
current[key] = LIST_KEYS.has(key) ? splitList(value) : numeric(value);
|
|
790
|
+
}
|
|
791
|
+
finish();
|
|
792
|
+
return statements;
|
|
793
|
+
}
|
|
485
794
|
var PlanNodeSchema = z.looseObject({
|
|
486
795
|
"Node Type": z.string(),
|
|
487
796
|
get Plans() {
|
|
@@ -590,6 +899,10 @@ function normalizeNode(raw, nextId) {
|
|
|
590
899
|
diskUsage: num(raw, "Disk Usage"),
|
|
591
900
|
exactHeapBlocks: num(raw, "Exact Heap Blocks"),
|
|
592
901
|
lossyHeapBlocks: num(raw, "Lossy Heap Blocks"),
|
|
902
|
+
cacheHits: num(raw, "Cache Hits"),
|
|
903
|
+
cacheMisses: num(raw, "Cache Misses"),
|
|
904
|
+
cacheEvictions: num(raw, "Cache Evictions"),
|
|
905
|
+
cacheOverflows: num(raw, "Cache Overflows"),
|
|
593
906
|
sharedHitBlocks: num(raw, "Shared Hit Blocks"),
|
|
594
907
|
sharedReadBlocks: num(raw, "Shared Read Blocks"),
|
|
595
908
|
sharedDirtiedBlocks: num(raw, "Shared Dirtied Blocks"),
|
|
@@ -601,8 +914,13 @@ function normalizeNode(raw, nextId) {
|
|
|
601
914
|
ioReadTime: num(raw, "I/O Read Time"),
|
|
602
915
|
ioWriteTime: num(raw, "I/O Write Time"),
|
|
603
916
|
workersPlanned: num(raw, "Workers Planned"),
|
|
604
|
-
workersLaunched: num(raw, "Workers Launched")
|
|
917
|
+
workersLaunched: num(raw, "Workers Launched"),
|
|
918
|
+
walRecords: num(raw, "WAL Records"),
|
|
919
|
+
walBytes: num(raw, "WAL Bytes"),
|
|
920
|
+
walFpi: num(raw, "WAL FPI")
|
|
605
921
|
});
|
|
922
|
+
const workers = parseWorkers(raw.Workers);
|
|
923
|
+
if (workers.length) node.workers = workers;
|
|
606
924
|
const childPlans = raw.Plans;
|
|
607
925
|
if (Array.isArray(childPlans)) {
|
|
608
926
|
for (const child of childPlans) {
|
|
@@ -616,6 +934,24 @@ function assign(target, fields) {
|
|
|
616
934
|
if (v !== void 0) target[k] = v;
|
|
617
935
|
}
|
|
618
936
|
}
|
|
937
|
+
function parseWorkers(raw) {
|
|
938
|
+
if (!Array.isArray(raw)) return [];
|
|
939
|
+
const out = [];
|
|
940
|
+
for (const w of raw) {
|
|
941
|
+
const r = w;
|
|
942
|
+
const number = num(r, "Worker Number");
|
|
943
|
+
if (number === void 0) continue;
|
|
944
|
+
const stat2 = { number };
|
|
945
|
+
assign(stat2, {
|
|
946
|
+
actualRows: num(r, "Actual Rows"),
|
|
947
|
+
actualLoops: num(r, "Actual Loops"),
|
|
948
|
+
actualStartupTime: num(r, "Actual Startup Time"),
|
|
949
|
+
actualTotalTime: num(r, "Actual Total Time")
|
|
950
|
+
});
|
|
951
|
+
out.push(stat2);
|
|
952
|
+
}
|
|
953
|
+
return out;
|
|
954
|
+
}
|
|
619
955
|
function parseTriggers(raw) {
|
|
620
956
|
if (!Array.isArray(raw)) return [];
|
|
621
957
|
return raw.map((t) => {
|
|
@@ -649,6 +985,36 @@ function parseJit(raw) {
|
|
|
649
985
|
}
|
|
650
986
|
return jit;
|
|
651
987
|
}
|
|
988
|
+
function statementToTree(stmt) {
|
|
989
|
+
let id = 0;
|
|
990
|
+
const root = normalizeNode(stmt.Plan, () => id++);
|
|
991
|
+
const hasAnalyze = root.actualLoops !== void 0 || stmt["Execution Time"] !== void 0;
|
|
992
|
+
const hasBuffers = root.sharedHitBlocks !== void 0 || root.sharedReadBlocks !== void 0;
|
|
993
|
+
const tree = {
|
|
994
|
+
root,
|
|
995
|
+
triggers: parseTriggers(stmt.Triggers),
|
|
996
|
+
hasAnalyze,
|
|
997
|
+
hasBuffers,
|
|
998
|
+
raw: stmt.Plan
|
|
999
|
+
};
|
|
1000
|
+
if (typeof stmt["Planning Time"] === "number") tree.planningTime = stmt["Planning Time"];
|
|
1001
|
+
if (typeof stmt["Execution Time"] === "number") tree.executionTime = stmt["Execution Time"];
|
|
1002
|
+
const serialization = stmt.Serialization;
|
|
1003
|
+
if (serialization && typeof serialization === "object") {
|
|
1004
|
+
const t = num(serialization, "Time");
|
|
1005
|
+
if (t !== void 0) tree.serializationTime = t;
|
|
1006
|
+
}
|
|
1007
|
+
const jit = parseJit(stmt.JIT);
|
|
1008
|
+
if (jit) tree.jit = jit;
|
|
1009
|
+
if (stmt.Settings) tree.settings = stmt.Settings;
|
|
1010
|
+
return tree;
|
|
1011
|
+
}
|
|
1012
|
+
function parseExplain(input) {
|
|
1013
|
+
return /^\s*[[{]/.test(input) ? parseExplainJson(input) : parseExplainText(input);
|
|
1014
|
+
}
|
|
1015
|
+
function parseExplainText(input) {
|
|
1016
|
+
return parseTextToStatements(input).map(statementToTree);
|
|
1017
|
+
}
|
|
652
1018
|
function parseExplainJson(input) {
|
|
653
1019
|
const json = parseJsonWithLocation(input);
|
|
654
1020
|
let candidate = json;
|
|
@@ -663,25 +1029,7 @@ function parseExplainJson(input) {
|
|
|
663
1029
|
location: { kind: "input" }
|
|
664
1030
|
});
|
|
665
1031
|
}
|
|
666
|
-
return result.data.map((stmt) =>
|
|
667
|
-
let id = 0;
|
|
668
|
-
const root = normalizeNode(stmt.Plan, () => id++);
|
|
669
|
-
const hasAnalyze = root.actualLoops !== void 0 || stmt["Execution Time"] !== void 0;
|
|
670
|
-
const hasBuffers = root.sharedHitBlocks !== void 0 || root.sharedReadBlocks !== void 0;
|
|
671
|
-
const tree = {
|
|
672
|
-
root,
|
|
673
|
-
triggers: parseTriggers(stmt.Triggers),
|
|
674
|
-
hasAnalyze,
|
|
675
|
-
hasBuffers,
|
|
676
|
-
raw: stmt.Plan
|
|
677
|
-
};
|
|
678
|
-
if (stmt["Planning Time"] !== void 0) tree.planningTime = stmt["Planning Time"];
|
|
679
|
-
if (stmt["Execution Time"] !== void 0) tree.executionTime = stmt["Execution Time"];
|
|
680
|
-
const jit = parseJit(stmt.JIT);
|
|
681
|
-
if (jit) tree.jit = jit;
|
|
682
|
-
if (stmt.Settings) tree.settings = stmt.Settings;
|
|
683
|
-
return tree;
|
|
684
|
-
});
|
|
1032
|
+
return result.data.map((stmt) => statementToTree(stmt));
|
|
685
1033
|
}
|
|
686
1034
|
function walk(node, visit) {
|
|
687
1035
|
visit(node);
|
|
@@ -746,6 +1094,31 @@ function executionMs(tree) {
|
|
|
746
1094
|
function bottlenecks(tree, n = 5) {
|
|
747
1095
|
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);
|
|
748
1096
|
}
|
|
1097
|
+
function aggregateStats(tree) {
|
|
1098
|
+
const total = executionMs(tree) ?? 0;
|
|
1099
|
+
const groupBy = (keyOf) => {
|
|
1100
|
+
const acc = /* @__PURE__ */ new Map();
|
|
1101
|
+
for (const n of flatten(tree.root)) {
|
|
1102
|
+
const key = keyOf(n);
|
|
1103
|
+
if (!key) continue;
|
|
1104
|
+
const e = acc.get(key) ?? { count: 0, selfMs: 0 };
|
|
1105
|
+
e.count++;
|
|
1106
|
+
e.selfMs += n.metrics.selfMs ?? 0;
|
|
1107
|
+
acc.set(key, e);
|
|
1108
|
+
}
|
|
1109
|
+
return [...acc.entries()].map(([key, e]) => ({
|
|
1110
|
+
key,
|
|
1111
|
+
count: e.count,
|
|
1112
|
+
selfMs: e.selfMs,
|
|
1113
|
+
pctOfTotal: total > 0 ? 100 * e.selfMs / total : 0
|
|
1114
|
+
})).sort((a, b) => b.selfMs - a.selfMs || b.count - a.count);
|
|
1115
|
+
};
|
|
1116
|
+
return {
|
|
1117
|
+
byNodeType: groupBy((n) => n.nodeType),
|
|
1118
|
+
byRelation: groupBy((n) => n.relationName),
|
|
1119
|
+
byIndex: groupBy((n) => n.indexName)
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
749
1122
|
function nodeLabel(node) {
|
|
750
1123
|
let label = node.nodeType;
|
|
751
1124
|
if (node.indexName && node.relationName)
|
|
@@ -877,8 +1250,11 @@ var cartesianProduct = {
|
|
|
877
1250
|
check(node, ctx) {
|
|
878
1251
|
if (node.nodeType !== "Nested Loop") return [];
|
|
879
1252
|
if (node.joinFilter) return [];
|
|
880
|
-
|
|
1253
|
+
let inner = node.children[1];
|
|
881
1254
|
if (!inner) return [];
|
|
1255
|
+
while ((inner.nodeType === "Memoize" || inner.nodeType === "Materialize") && inner.children[0]) {
|
|
1256
|
+
inner = inner.children[0];
|
|
1257
|
+
}
|
|
882
1258
|
if (inner.indexCond || inner.recheckCond) return [];
|
|
883
1259
|
const outer = node.children[0];
|
|
884
1260
|
if (!outer) return [];
|
|
@@ -1197,6 +1573,47 @@ var indexOnlyHeapFetches = {
|
|
|
1197
1573
|
}
|
|
1198
1574
|
};
|
|
1199
1575
|
|
|
1576
|
+
// src/advisor/rules/limit-large-offset.ts
|
|
1577
|
+
var limitLargeOffset = {
|
|
1578
|
+
id: "PGX_LIMIT_LARGE_OFFSET",
|
|
1579
|
+
title: "LIMIT discards a large prefix (OFFSET pagination)",
|
|
1580
|
+
defaultSeverity: "warn",
|
|
1581
|
+
requiresAnalyze: true,
|
|
1582
|
+
check(node, ctx) {
|
|
1583
|
+
if (node.nodeType !== "Limit") return [];
|
|
1584
|
+
const child = outerChild(node);
|
|
1585
|
+
const emitted = node.metrics.totalRows;
|
|
1586
|
+
const produced = child?.metrics.totalRows;
|
|
1587
|
+
if (emitted === void 0 || produced === void 0) return [];
|
|
1588
|
+
const discarded = produced - emitted;
|
|
1589
|
+
if (discarded < ctx.thresholds.limitDiscardRows) return [];
|
|
1590
|
+
const rel = child?.relationName ?? "the input";
|
|
1591
|
+
return [
|
|
1592
|
+
makeFinding(limitLargeOffset, ctx, node, {
|
|
1593
|
+
title: `LIMIT discarded ${fmtInt(discarded)} rows before returning ${fmtInt(emitted)}`,
|
|
1594
|
+
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.`,
|
|
1595
|
+
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)).",
|
|
1596
|
+
remediation: {
|
|
1597
|
+
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.",
|
|
1598
|
+
steps: [
|
|
1599
|
+
"Order by a unique (or tie-broken) key, e.g. ORDER BY created_at, id.",
|
|
1600
|
+
"Pass the last row's key from the previous page instead of an OFFSET.",
|
|
1601
|
+
"Index the sort key so the WHERE clause seeks directly to the page start."
|
|
1602
|
+
],
|
|
1603
|
+
commands: [
|
|
1604
|
+
{
|
|
1605
|
+
label: "Keyset pagination instead of OFFSET",
|
|
1606
|
+
sql: "SELECT \u2026 FROM t WHERE (created_at, id) > ($last_created_at, $last_id) ORDER BY created_at, id LIMIT 50;"
|
|
1607
|
+
}
|
|
1608
|
+
]
|
|
1609
|
+
},
|
|
1610
|
+
docsUrl: `${DOCS2}/queries-limit.html`,
|
|
1611
|
+
meta: { discarded: Math.round(discarded), emitted: Math.round(emitted) }
|
|
1612
|
+
})
|
|
1613
|
+
];
|
|
1614
|
+
}
|
|
1615
|
+
};
|
|
1616
|
+
|
|
1200
1617
|
// src/advisor/rules/low-cache-hit.ts
|
|
1201
1618
|
var MIN_READ_BLOCKS = 1e3;
|
|
1202
1619
|
var lowCacheHit = {
|
|
@@ -1243,6 +1660,46 @@ var lowCacheHit = {
|
|
|
1243
1660
|
}
|
|
1244
1661
|
};
|
|
1245
1662
|
|
|
1663
|
+
// src/advisor/rules/memoize-evictions.ts
|
|
1664
|
+
var memoizeEvictions = {
|
|
1665
|
+
id: "PGX_MEMOIZE_EVICTIONS",
|
|
1666
|
+
title: "Memoize cache is thrashing",
|
|
1667
|
+
defaultSeverity: "warn",
|
|
1668
|
+
requiresAnalyze: true,
|
|
1669
|
+
check(node, ctx) {
|
|
1670
|
+
if (node.nodeType !== "Memoize") return [];
|
|
1671
|
+
const hits = node.cacheHits ?? 0;
|
|
1672
|
+
const evictions = node.cacheEvictions ?? 0;
|
|
1673
|
+
const overflows = node.cacheOverflows ?? 0;
|
|
1674
|
+
const thrashing = evictions > hits;
|
|
1675
|
+
if (!thrashing && overflows === 0) return [];
|
|
1676
|
+
return [
|
|
1677
|
+
makeFinding(memoizeEvictions, ctx, node, {
|
|
1678
|
+
title: overflows > 0 ? `Memoize cache overflowed ${fmtInt(overflows)} time(s)` : `Memoize evicted ${fmtInt(evictions)} entries against ${fmtInt(hits)} hits`,
|
|
1679
|
+
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.`,
|
|
1680
|
+
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.",
|
|
1681
|
+
remediation: {
|
|
1682
|
+
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.",
|
|
1683
|
+
steps: [
|
|
1684
|
+
"Estimate the distinct keys: the planner sizes the cache from ndistinct of the join key.",
|
|
1685
|
+
"Raise work_mem (or hash_mem_multiplier on PG 15+) for this workload and re-run.",
|
|
1686
|
+
"If the key space is genuinely huge, an index on the inner side may beat Memoize \u2014 compare with enable_memoize = off."
|
|
1687
|
+
],
|
|
1688
|
+
commands: [
|
|
1689
|
+
{ label: "More cache memory for this session", sql: "SET work_mem = '64MB';" },
|
|
1690
|
+
{
|
|
1691
|
+
label: "Compare the plan without Memoize",
|
|
1692
|
+
sql: "SET enable_memoize = off; EXPLAIN ANALYZE <query>;"
|
|
1693
|
+
}
|
|
1694
|
+
]
|
|
1695
|
+
},
|
|
1696
|
+
docsUrl: `${DOCS2}/runtime-config-resource.html`,
|
|
1697
|
+
meta: { hits, evictions, overflows }
|
|
1698
|
+
})
|
|
1699
|
+
];
|
|
1700
|
+
}
|
|
1701
|
+
};
|
|
1702
|
+
|
|
1246
1703
|
// src/advisor/rules/nested-loop-large-outer.ts
|
|
1247
1704
|
var nestedLoopLargeOuter = {
|
|
1248
1705
|
id: "PGX_NESTED_LOOP_LARGE_OUTER",
|
|
@@ -1330,8 +1787,8 @@ var rowMisestimate = {
|
|
|
1330
1787
|
const target = rel ?? "the underlying table";
|
|
1331
1788
|
const under = estimateDirection === "under";
|
|
1332
1789
|
const direction = under ? "underestimate" : "overestimate";
|
|
1333
|
-
const
|
|
1334
|
-
const detail = `Postgres estimated ${fmtInt(node.planRows)} rows but ${fmtInt(
|
|
1790
|
+
const actual2 = totalRows ?? 0;
|
|
1791
|
+
const detail = `Postgres estimated ${fmtInt(node.planRows)} rows but ${fmtInt(actual2)} were produced \u2014 a ${fmtInt(factor)}x ${direction}${onRel}.`;
|
|
1335
1792
|
return [
|
|
1336
1793
|
makeFinding(rowMisestimate, ctx, node, {
|
|
1337
1794
|
// Severity: underestimates are the dangerous ones (under-sized joins/memory).
|
|
@@ -1366,7 +1823,7 @@ ANALYZE ${rel ?? "<relation>"};`
|
|
|
1366
1823
|
docsUrl: `${DOCS2}/planner-stats.html`,
|
|
1367
1824
|
meta: {
|
|
1368
1825
|
estimatedRows: Math.round(node.planRows),
|
|
1369
|
-
actualRows: Math.round(
|
|
1826
|
+
actualRows: Math.round(actual2),
|
|
1370
1827
|
factor,
|
|
1371
1828
|
direction: estimateDirection
|
|
1372
1829
|
}
|
|
@@ -1625,8 +2082,10 @@ var ALL_RULES = [
|
|
|
1625
2082
|
seqScanLarge,
|
|
1626
2083
|
nestedLoopLargeOuter,
|
|
1627
2084
|
highFilterDiscard,
|
|
2085
|
+
limitLargeOffset,
|
|
1628
2086
|
sortSpillDisk,
|
|
1629
2087
|
hashSpillDisk,
|
|
2088
|
+
memoizeEvictions,
|
|
1630
2089
|
correlatedSubplan,
|
|
1631
2090
|
rowMisestimate,
|
|
1632
2091
|
filterCouldBeIndexCond,
|
|
@@ -1654,7 +2113,7 @@ function runAdvisor(tree, config = DEFAULT_CONFIG) {
|
|
|
1654
2113
|
if (rule.requiresAnalyze && !tree.hasAnalyze) continue;
|
|
1655
2114
|
if (rule.requiresBuffers && !tree.hasBuffers) continue;
|
|
1656
2115
|
for (const node of nodes) {
|
|
1657
|
-
for (const
|
|
2116
|
+
for (const finding2 of rule.check(node, ctx)) diagnostics.push(finding2);
|
|
1658
2117
|
}
|
|
1659
2118
|
}
|
|
1660
2119
|
diagnostics.sort(bySeverity);
|
|
@@ -1711,6 +2170,138 @@ function redactPlanTree(tree) {
|
|
|
1711
2170
|
walk(tree.root, redactNode);
|
|
1712
2171
|
}
|
|
1713
2172
|
|
|
2173
|
+
// src/locks/advisor.ts
|
|
2174
|
+
var DOCS3 = "https://www.postgresql.org/docs/current";
|
|
2175
|
+
function analyzeLocks(sql, tree) {
|
|
2176
|
+
const code = stripSql(sql);
|
|
2177
|
+
const upper = code.toUpperCase();
|
|
2178
|
+
const kw = (code.trim().split(/\s+/)[0] ?? "").toUpperCase();
|
|
2179
|
+
const out = [];
|
|
2180
|
+
const add = (id, severity, parts) => {
|
|
2181
|
+
out.push({
|
|
2182
|
+
code: id,
|
|
2183
|
+
domain: "plan",
|
|
2184
|
+
severity,
|
|
2185
|
+
title: parts.title,
|
|
2186
|
+
detail: parts.detail,
|
|
2187
|
+
cause: parts.cause,
|
|
2188
|
+
remediation: { summary: parts.fix, commands: parts.commands },
|
|
2189
|
+
docsUrl: `${DOCS3}/explicit-locking.html`
|
|
2190
|
+
});
|
|
2191
|
+
};
|
|
2192
|
+
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)) {
|
|
2193
|
+
add("PGX_LOCK_TABLE_REWRITE", "error", {
|
|
2194
|
+
title: "Operation rewrites the table under an ACCESS EXCLUSIVE lock",
|
|
2195
|
+
detail: "VACUUM FULL / CLUSTER / a column-type change rewrites the whole table and holds ACCESS EXCLUSIVE for the duration.",
|
|
2196
|
+
cause: "ACCESS EXCLUSIVE blocks every reader and writer until the rewrite finishes \u2014 an outage on a busy table.",
|
|
2197
|
+
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.",
|
|
2198
|
+
commands: [{ label: "Bound the wait", sql: "SET lock_timeout = '3s';" }]
|
|
2199
|
+
});
|
|
2200
|
+
}
|
|
2201
|
+
if (/\bCREATE\s+(UNIQUE\s+)?INDEX\b/.test(upper) && !/\bCONCURRENTLY\b/.test(upper)) {
|
|
2202
|
+
add("PGX_DDL_NO_CONCURRENTLY", "warn", {
|
|
2203
|
+
title: "CREATE INDEX without CONCURRENTLY blocks writes",
|
|
2204
|
+
detail: "A plain CREATE INDEX takes a SHARE lock, blocking all writes to the table until the build completes.",
|
|
2205
|
+
cause: "On a large or busy table the build can take minutes, during which inserts/updates/deletes are blocked.",
|
|
2206
|
+
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).",
|
|
2207
|
+
commands: [{ label: "Build online", sql: "CREATE INDEX CONCURRENTLY ON <table> (<cols>);" }]
|
|
2208
|
+
});
|
|
2209
|
+
}
|
|
2210
|
+
if (/\bDROP\s+INDEX\b/.test(upper) && !/\bCONCURRENTLY\b/.test(upper)) {
|
|
2211
|
+
add("PGX_DROP_INDEX_NO_CONCURRENTLY", "warn", {
|
|
2212
|
+
title: "DROP INDEX without CONCURRENTLY takes ACCESS EXCLUSIVE",
|
|
2213
|
+
detail: "A plain DROP INDEX locks the table with ACCESS EXCLUSIVE.",
|
|
2214
|
+
cause: "Readers and writers block until the drop completes.",
|
|
2215
|
+
fix: "Use DROP INDEX CONCURRENTLY to avoid blocking.",
|
|
2216
|
+
commands: [{ label: "Drop online", sql: "DROP INDEX CONCURRENTLY <index>;" }]
|
|
2217
|
+
});
|
|
2218
|
+
}
|
|
2219
|
+
if (/\bTRUNCATE\b/.test(upper)) {
|
|
2220
|
+
add("PGX_LOCK_TRUNCATE", "info", {
|
|
2221
|
+
title: "TRUNCATE takes an ACCESS EXCLUSIVE lock",
|
|
2222
|
+
detail: "TRUNCATE briefly locks the table with ACCESS EXCLUSIVE.",
|
|
2223
|
+
cause: "It is fast (no row scan) but still blocks all access while it runs and is transactional.",
|
|
2224
|
+
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.",
|
|
2225
|
+
commands: [{ label: "Bound the wait", sql: "SET lock_timeout = '3s';" }]
|
|
2226
|
+
});
|
|
2227
|
+
}
|
|
2228
|
+
if (/\bLOCK\s+TABLE\b/.test(upper)) {
|
|
2229
|
+
add("PGX_LOCK_TABLE_EXPLICIT", "info", {
|
|
2230
|
+
title: "Explicit LOCK TABLE",
|
|
2231
|
+
detail: "An explicit LOCK TABLE acquires the named lock mode for the rest of the transaction.",
|
|
2232
|
+
cause: "Holding a strong lock longer than necessary blocks other sessions.",
|
|
2233
|
+
fix: "Use the lowest lock mode that suffices and keep the transaction short."
|
|
2234
|
+
});
|
|
2235
|
+
}
|
|
2236
|
+
if (/\bFOR\s+(UPDATE|SHARE|NO\s+KEY\s+UPDATE|KEY\s+SHARE)\b/.test(upper) && !/\bLIMIT\b/.test(upper)) {
|
|
2237
|
+
add("PGX_SELECT_FOR_UPDATE_UNBOUNDED", "warn", {
|
|
2238
|
+
title: "Row-locking SELECT without a LIMIT",
|
|
2239
|
+
detail: "SELECT \u2026 FOR UPDATE/SHARE locks every row it matches, held until the transaction ends.",
|
|
2240
|
+
cause: "Locking an unbounded set increases contention and deadlock risk with concurrent updaters.",
|
|
2241
|
+
fix: "Bound the set with a deterministic ORDER BY + LIMIT (and process in batches); a consistent lock order also avoids deadlocks.",
|
|
2242
|
+
commands: [{ label: "Bound + order", sql: "SELECT \u2026 ORDER BY id FOR UPDATE LIMIT 100;" }]
|
|
2243
|
+
});
|
|
2244
|
+
}
|
|
2245
|
+
if (kw === "UPDATE" || kw === "DELETE") {
|
|
2246
|
+
if (!/\bWHERE\b/.test(upper)) {
|
|
2247
|
+
add("PGX_WRITE_NO_WHERE", "warn", {
|
|
2248
|
+
title: `${kw} without a WHERE clause locks every row`,
|
|
2249
|
+
detail: `This ${kw} touches the whole table, taking a row lock on every row until commit.`,
|
|
2250
|
+
cause: "All rows are locked for the transaction's duration, blocking concurrent writers and bloating the table.",
|
|
2251
|
+
fix: "Add a WHERE clause; for large rewrites, update in batches (e.g. by primary-key ranges) and commit between batches."
|
|
2252
|
+
});
|
|
2253
|
+
} else if (tree && hasSeqScanOnTarget(tree, targetTable(code, kw))) {
|
|
2254
|
+
const rel = targetTable(code, kw);
|
|
2255
|
+
add("PGX_UPDATE_UNINDEXED_PREDICATE", "warn", {
|
|
2256
|
+
title: `${kw} scans ${rel ?? "the table"} sequentially to find rows`,
|
|
2257
|
+
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.`,
|
|
2258
|
+
cause: "An unindexed predicate means more rows scanned and locked, and the locks are held until commit.",
|
|
2259
|
+
fix: `Index the ${kw}'s WHERE columns so it finds rows via an index and locks only what it changes.`,
|
|
2260
|
+
commands: [
|
|
2261
|
+
{
|
|
2262
|
+
label: "Index the predicate",
|
|
2263
|
+
sql: `CREATE INDEX ON ${rel ?? "<table>"} (<where columns>);`
|
|
2264
|
+
}
|
|
2265
|
+
]
|
|
2266
|
+
});
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
if (/^(ALTER|CREATE|DROP)\b/.test(kw) && !/\bCONCURRENTLY\b/.test(upper) && !/\bSET\s+LOCK_TIMEOUT\b/.test(upper)) {
|
|
2270
|
+
add("PGX_DDL_NO_LOCK_TIMEOUT", "warn", {
|
|
2271
|
+
title: "DDL without a lock_timeout can stall the whole table",
|
|
2272
|
+
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.",
|
|
2273
|
+
cause: "A blocked ACCESS EXCLUSIVE request sits at the head of the lock queue and blocks new readers/writers too.",
|
|
2274
|
+
fix: "Set a short lock_timeout before the DDL and retry, so it fails fast instead of forming a queue.",
|
|
2275
|
+
commands: [
|
|
2276
|
+
{
|
|
2277
|
+
label: "Fail fast, then retry",
|
|
2278
|
+
sql: "SET lock_timeout = '3s';\n-- run the DDL; on timeout, retry later"
|
|
2279
|
+
}
|
|
2280
|
+
]
|
|
2281
|
+
});
|
|
2282
|
+
}
|
|
2283
|
+
return out;
|
|
2284
|
+
}
|
|
2285
|
+
function stripSql(sql) {
|
|
2286
|
+
return sql.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/--[^\n]*/g, " ").replace(/'(?:[^']|'')*'/g, "''").replace(/"(?:[^"]|"")*"/g, '"x"');
|
|
2287
|
+
}
|
|
2288
|
+
function targetTable(code, kw) {
|
|
2289
|
+
const re = kw === "DELETE" ? /\bDELETE\s+FROM\s+([A-Za-z_][\w.]*)/i : /\bUPDATE\s+(?:ONLY\s+)?([A-Za-z_][\w.]*)/i;
|
|
2290
|
+
const m = re.exec(code);
|
|
2291
|
+
return m?.[1];
|
|
2292
|
+
}
|
|
2293
|
+
function hasSeqScanOnTarget(tree, table) {
|
|
2294
|
+
let found = false;
|
|
2295
|
+
walk(tree.root, (n) => {
|
|
2296
|
+
if (n.nodeType === "Seq Scan" && (!table || n.relationName === bareName(table))) found = true;
|
|
2297
|
+
});
|
|
2298
|
+
return found;
|
|
2299
|
+
}
|
|
2300
|
+
function bareName(qualified) {
|
|
2301
|
+
const parts = qualified.split(".");
|
|
2302
|
+
return parts[parts.length - 1] ?? qualified;
|
|
2303
|
+
}
|
|
2304
|
+
|
|
1714
2305
|
// src/report/tree.ts
|
|
1715
2306
|
function treeLines(tree, glyphs) {
|
|
1716
2307
|
const lines = [];
|
|
@@ -1751,21 +2342,28 @@ function nodeSummary(node) {
|
|
|
1751
2342
|
// src/report/json.ts
|
|
1752
2343
|
var JSON_SCHEMA_VERSION = 1;
|
|
1753
2344
|
function renderJson(result, pretty = true) {
|
|
2345
|
+
return JSON.stringify(buildReport(result), null, pretty ? 2 : 0);
|
|
2346
|
+
}
|
|
2347
|
+
function buildReport(result) {
|
|
1754
2348
|
const { tree, diagnostics, bottlenecks: bottlenecks2 } = result;
|
|
1755
2349
|
const counts = { error: 0, warn: 0, info: 0 };
|
|
1756
2350
|
for (const d of diagnostics) counts[d.severity]++;
|
|
1757
|
-
|
|
2351
|
+
return {
|
|
1758
2352
|
schemaVersion: JSON_SCHEMA_VERSION,
|
|
1759
2353
|
verdict: result.verdict,
|
|
1760
2354
|
worstSeverity: result.worstSeverity,
|
|
1761
2355
|
summary: {
|
|
1762
2356
|
planningTimeMs: tree.planningTime ?? null,
|
|
1763
2357
|
executionTimeMs: executionMs(tree) ?? null,
|
|
2358
|
+
serializationTimeMs: tree.serializationTime ?? null,
|
|
1764
2359
|
hasAnalyze: tree.hasAnalyze,
|
|
1765
2360
|
hasBuffers: tree.hasBuffers,
|
|
1766
2361
|
nodeCount: flatten(tree.root).length,
|
|
1767
2362
|
findings: counts
|
|
1768
2363
|
},
|
|
2364
|
+
triggers: tree.triggers,
|
|
2365
|
+
jit: tree.jit ?? null,
|
|
2366
|
+
settings: tree.settings ?? null,
|
|
1769
2367
|
diagnostics,
|
|
1770
2368
|
bottlenecks: bottlenecks2.filter((n) => (n.metrics.selfMs ?? 0) > 0).map((n) => ({
|
|
1771
2369
|
id: n.id,
|
|
@@ -1776,9 +2374,9 @@ function renderJson(result, pretty = true) {
|
|
|
1776
2374
|
pctOfTotal: n.metrics.pctOfTotal ?? null,
|
|
1777
2375
|
totalRows: n.metrics.totalRows ?? null
|
|
1778
2376
|
})),
|
|
2377
|
+
stats: aggregateStats(tree),
|
|
1779
2378
|
plan: serializeNode(tree.root)
|
|
1780
2379
|
};
|
|
1781
|
-
return JSON.stringify(report, null, pretty ? 2 : 0);
|
|
1782
2380
|
}
|
|
1783
2381
|
function serializeNode(node) {
|
|
1784
2382
|
const { children, metrics, raw, ...fields } = node;
|
|
@@ -2074,14 +2672,15 @@ function render(result, opts) {
|
|
|
2074
2672
|
|
|
2075
2673
|
// src/index.ts
|
|
2076
2674
|
function analyze(input, options = {}) {
|
|
2077
|
-
const trees =
|
|
2675
|
+
const trees = parseExplain(input);
|
|
2078
2676
|
const tree = selectStatement(trees, options.statement);
|
|
2079
2677
|
if (options.redact) redactPlanTree(tree);
|
|
2080
2678
|
computeMetrics(tree);
|
|
2081
2679
|
const result = runAdvisor(tree, options.config ?? DEFAULT_CONFIG);
|
|
2082
|
-
const
|
|
2083
|
-
if (
|
|
2084
|
-
|
|
2680
|
+
const extra = planNotices(tree);
|
|
2681
|
+
if (options.sql) extra.push(...analyzeLocks(options.sql, tree));
|
|
2682
|
+
if (extra.length) {
|
|
2683
|
+
result.diagnostics = [...result.diagnostics, ...extra].sort(bySeverity);
|
|
2085
2684
|
result.worstSeverity = result.diagnostics.reduce(
|
|
2086
2685
|
(worst, d) => worst === null ? d.severity : maxSeverity(worst, d.severity),
|
|
2087
2686
|
null
|
|
@@ -2163,6 +2762,50 @@ async function resolvePlanInput(file) {
|
|
|
2163
2762
|
if (!text.trim()) throw opError("PGX_EMPTY_INPUT");
|
|
2164
2763
|
return text;
|
|
2165
2764
|
}
|
|
2765
|
+
|
|
2766
|
+
// src/util/log.ts
|
|
2767
|
+
var level = "normal";
|
|
2768
|
+
function setLogLevel(l) {
|
|
2769
|
+
level = l;
|
|
2770
|
+
}
|
|
2771
|
+
function isDebug() {
|
|
2772
|
+
return level === "debug";
|
|
2773
|
+
}
|
|
2774
|
+
function write(msg) {
|
|
2775
|
+
process.stderr.write(`${msg}
|
|
2776
|
+
`);
|
|
2777
|
+
}
|
|
2778
|
+
function logInfo(msg) {
|
|
2779
|
+
if (level !== "quiet") write(msg);
|
|
2780
|
+
}
|
|
2781
|
+
function logVerbose(msg) {
|
|
2782
|
+
if (level === "verbose" || level === "debug") write(msg);
|
|
2783
|
+
}
|
|
2784
|
+
function logError(msg) {
|
|
2785
|
+
write(msg);
|
|
2786
|
+
}
|
|
2787
|
+
function browserCommand(platform) {
|
|
2788
|
+
if (platform === "darwin") return "open";
|
|
2789
|
+
if (platform === "win32") return "start";
|
|
2790
|
+
return "xdg-open";
|
|
2791
|
+
}
|
|
2792
|
+
function openInBrowser(target, platform = process.platform) {
|
|
2793
|
+
const cmd = browserCommand(platform);
|
|
2794
|
+
try {
|
|
2795
|
+
const child = spawn(cmd, [target], {
|
|
2796
|
+
stdio: "ignore",
|
|
2797
|
+
detached: true,
|
|
2798
|
+
shell: platform === "win32"
|
|
2799
|
+
// `start` is a shell builtin
|
|
2800
|
+
});
|
|
2801
|
+
child.on("error", () => {
|
|
2802
|
+
});
|
|
2803
|
+
child.unref();
|
|
2804
|
+
} catch {
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
// src/commands/emit.ts
|
|
2166
2809
|
async function emit(result, opts) {
|
|
2167
2810
|
configureColor(opts.format === "terminal" ? opts.color : "never");
|
|
2168
2811
|
const text = render(result, {
|
|
@@ -2173,6 +2816,12 @@ async function emit(result, opts) {
|
|
|
2173
2816
|
});
|
|
2174
2817
|
if (opts.output) {
|
|
2175
2818
|
await writeFile(opts.output, text);
|
|
2819
|
+
if (opts.format === "html" && opts.openHtml) openInBrowser(opts.output);
|
|
2820
|
+
} else if (opts.format === "html" && opts.openHtml) {
|
|
2821
|
+
const file = join(tmpdir(), `pg-explain-${Date.now()}.html`);
|
|
2822
|
+
await writeFile(file, text);
|
|
2823
|
+
logInfo(`Opened HTML report: ${file}`);
|
|
2824
|
+
openInBrowser(file);
|
|
2176
2825
|
} else {
|
|
2177
2826
|
process.stdout.write(text.endsWith("\n") ? text : `${text}
|
|
2178
2827
|
`);
|
|
@@ -2253,7 +2902,7 @@ function gateTrips(result, failOn) {
|
|
|
2253
2902
|
}
|
|
2254
2903
|
|
|
2255
2904
|
// src/commands/completion.ts
|
|
2256
|
-
var SUBCOMMANDS = "run diff completion";
|
|
2905
|
+
var SUBCOMMANDS = "run diff locks studio completion";
|
|
2257
2906
|
var FLAGS = "--format --output --tldr --redact --ascii --color --no-color --fail-on --strict --config --statement --quiet --verbose --debug --help --version";
|
|
2258
2907
|
var FORMATS2 = "terminal markdown json html text";
|
|
2259
2908
|
var BASH = `# pg-explain bash completion
|
|
@@ -2499,28 +3148,6 @@ async function readPlan(path) {
|
|
|
2499
3148
|
}
|
|
2500
3149
|
}
|
|
2501
3150
|
|
|
2502
|
-
// src/util/log.ts
|
|
2503
|
-
var level = "normal";
|
|
2504
|
-
function setLogLevel(l) {
|
|
2505
|
-
level = l;
|
|
2506
|
-
}
|
|
2507
|
-
function isDebug() {
|
|
2508
|
-
return level === "debug";
|
|
2509
|
-
}
|
|
2510
|
-
function write(msg) {
|
|
2511
|
-
process.stderr.write(`${msg}
|
|
2512
|
-
`);
|
|
2513
|
-
}
|
|
2514
|
-
function logInfo(msg) {
|
|
2515
|
-
if (level !== "quiet") write(msg);
|
|
2516
|
-
}
|
|
2517
|
-
function logVerbose(msg) {
|
|
2518
|
-
if (level === "verbose" || level === "debug") write(msg);
|
|
2519
|
-
}
|
|
2520
|
-
function logError(msg) {
|
|
2521
|
-
write(msg);
|
|
2522
|
-
}
|
|
2523
|
-
|
|
2524
3151
|
// src/db/version.ts
|
|
2525
3152
|
function capabilities(versionNum) {
|
|
2526
3153
|
const major = Math.floor(versionNum / 1e4);
|
|
@@ -2742,6 +3369,86 @@ async function runExplain(opts) {
|
|
|
2742
3369
|
function msInt(ms) {
|
|
2743
3370
|
return Math.max(0, Math.floor(ms));
|
|
2744
3371
|
}
|
|
3372
|
+
async function queryReadOnly(connection, sql, params = [], timeoutMs = 1e4) {
|
|
3373
|
+
const ca = connection.sslrootcert ? await readFile(connection.sslrootcert, "utf8").catch(() => void 0) : void 0;
|
|
3374
|
+
const client = await newClient(buildClientConfig(connection, ca));
|
|
3375
|
+
try {
|
|
3376
|
+
await client.connect();
|
|
3377
|
+
} catch (err) {
|
|
3378
|
+
throw mapConnectError(err);
|
|
3379
|
+
}
|
|
3380
|
+
try {
|
|
3381
|
+
await client.query(`SET statement_timeout = ${msInt(timeoutMs)}`);
|
|
3382
|
+
const res = await client.query({ text: sql, values: params });
|
|
3383
|
+
return res.rows;
|
|
3384
|
+
} catch (err) {
|
|
3385
|
+
throw mapQueryError(err);
|
|
3386
|
+
} finally {
|
|
3387
|
+
await client.end().catch(() => {
|
|
3388
|
+
});
|
|
3389
|
+
}
|
|
3390
|
+
}
|
|
3391
|
+
async function explainScript(connection, units, opts) {
|
|
3392
|
+
const ca = connection.sslrootcert ? await readFile(connection.sslrootcert, "utf8").catch(() => void 0) : void 0;
|
|
3393
|
+
const client = await newClient(buildClientConfig(connection, ca));
|
|
3394
|
+
try {
|
|
3395
|
+
await client.connect();
|
|
3396
|
+
} catch (err) {
|
|
3397
|
+
throw mapConnectError(err);
|
|
3398
|
+
}
|
|
3399
|
+
try {
|
|
3400
|
+
const caps = capabilities(await fetchVersionNum(client));
|
|
3401
|
+
await client.query("BEGIN");
|
|
3402
|
+
const results = [];
|
|
3403
|
+
try {
|
|
3404
|
+
await client.query(`SET LOCAL statement_timeout = ${msInt(opts.statementTimeoutMs)}`);
|
|
3405
|
+
await client.query(`SET LOCAL lock_timeout = ${msInt(opts.lockTimeoutMs)}`);
|
|
3406
|
+
await client.query("SET LOCAL transaction_read_only = on");
|
|
3407
|
+
for (const unit of units) {
|
|
3408
|
+
const flags = {
|
|
3409
|
+
analyze: false,
|
|
3410
|
+
// never execute
|
|
3411
|
+
buffers: false,
|
|
3412
|
+
// BUFFERS requires ANALYZE pre-16
|
|
3413
|
+
verbose: opts.verbose ?? false,
|
|
3414
|
+
settings: opts.settings ?? false,
|
|
3415
|
+
wal: false,
|
|
3416
|
+
timing: false,
|
|
3417
|
+
costs: true,
|
|
3418
|
+
summary: false,
|
|
3419
|
+
genericPlan: caps.genericPlan && /\$\d+/.test(unit.sql),
|
|
3420
|
+
compat: true
|
|
3421
|
+
// auto-omit anything the server is too old for
|
|
3422
|
+
};
|
|
3423
|
+
try {
|
|
3424
|
+
const { prefix } = buildExplain(flags, caps);
|
|
3425
|
+
const res = await client.query(`${prefix} ${unit.sql}`);
|
|
3426
|
+
const r = { label: unit.label, planJson: extractPlanJson(res.rows) };
|
|
3427
|
+
if (unit.loopNote) r.loopNote = unit.loopNote;
|
|
3428
|
+
results.push(r);
|
|
3429
|
+
} catch (err) {
|
|
3430
|
+
const diag = err instanceof AppError ? err.diagnostic : mapQueryError(err).diagnostic;
|
|
3431
|
+
const r = { label: unit.label, error: diag };
|
|
3432
|
+
if (unit.loopNote) r.loopNote = unit.loopNote;
|
|
3433
|
+
results.push(r);
|
|
3434
|
+
await client.query("ROLLBACK").catch(() => {
|
|
3435
|
+
});
|
|
3436
|
+
await client.query("BEGIN").catch(() => {
|
|
3437
|
+
});
|
|
3438
|
+
await client.query("SET LOCAL transaction_read_only = on").catch(() => {
|
|
3439
|
+
});
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
return { units: results, caps };
|
|
3443
|
+
} finally {
|
|
3444
|
+
await client.query("ROLLBACK").catch(() => {
|
|
3445
|
+
});
|
|
3446
|
+
}
|
|
3447
|
+
} finally {
|
|
3448
|
+
await client.end().catch(() => {
|
|
3449
|
+
});
|
|
3450
|
+
}
|
|
3451
|
+
}
|
|
2745
3452
|
async function fetchVersionNum(client) {
|
|
2746
3453
|
try {
|
|
2747
3454
|
const res = await client.query("SHOW server_version_num");
|
|
@@ -2791,7 +3498,9 @@ function mapQueryError(err) {
|
|
|
2791
3498
|
if (err instanceof AppError) return err;
|
|
2792
3499
|
const e = asPgError(err);
|
|
2793
3500
|
const msg = e.message ?? "";
|
|
2794
|
-
const meta =
|
|
3501
|
+
const meta = {};
|
|
3502
|
+
if (e.code) meta.sqlState = e.code;
|
|
3503
|
+
if (e.position) meta.position = Number(e.position);
|
|
2795
3504
|
switch (e.code) {
|
|
2796
3505
|
case "57014":
|
|
2797
3506
|
return /statement timeout/i.test(msg) ? opError("PGX_STATEMENT_TIMEOUT", { detail: msg, meta }, err) : opError("PGX_QUERY_CANCELED", { detail: msg, meta }, err);
|
|
@@ -2811,34 +3520,501 @@ function mapQueryError(err) {
|
|
|
2811
3520
|
}
|
|
2812
3521
|
}
|
|
2813
3522
|
|
|
3523
|
+
// src/locks/live.ts
|
|
3524
|
+
var SQL = `
|
|
3525
|
+
SELECT a.pid,
|
|
3526
|
+
a.usename AS "user",
|
|
3527
|
+
a.state,
|
|
3528
|
+
a.wait_event_type AS "waitEventType",
|
|
3529
|
+
a.wait_event AS "waitEvent",
|
|
3530
|
+
EXTRACT(EPOCH FROM (now() - a.query_start)) AS "ageSeconds",
|
|
3531
|
+
a.query,
|
|
3532
|
+
pg_blocking_pids(a.pid) AS "blockedBy"
|
|
3533
|
+
FROM pg_stat_activity a
|
|
3534
|
+
WHERE a.backend_type = 'client backend' AND a.pid <> pg_backend_pid()
|
|
3535
|
+
ORDER BY cardinality(pg_blocking_pids(a.pid)) DESC, a.query_start NULLS LAST;
|
|
3536
|
+
`;
|
|
3537
|
+
async function liveLocks(connection, capturedAt) {
|
|
3538
|
+
const rows = await queryReadOnly(connection, SQL);
|
|
3539
|
+
const sessions = rows.map((r) => ({
|
|
3540
|
+
pid: r.pid,
|
|
3541
|
+
user: r.user,
|
|
3542
|
+
state: r.state,
|
|
3543
|
+
waitEventType: r.waitEventType,
|
|
3544
|
+
waitEvent: r.waitEvent,
|
|
3545
|
+
ageSeconds: r.ageSeconds == null ? null : Number(r.ageSeconds),
|
|
3546
|
+
query: r.query,
|
|
3547
|
+
blockedBy: r.blockedBy ?? []
|
|
3548
|
+
}));
|
|
3549
|
+
return { sessions, blocked: sessions.filter((s) => s.blockedBy.length > 0), capturedAt };
|
|
3550
|
+
}
|
|
3551
|
+
|
|
3552
|
+
// src/commands/locks.ts
|
|
3553
|
+
async function runLocks(args) {
|
|
3554
|
+
const snapshot = await liveLocks(args.connection, Date.now());
|
|
3555
|
+
configureColor(args.format === "terminal" ? args.color : "never");
|
|
3556
|
+
const text = args.format === "json" ? `${JSON.stringify(snapshot, null, 2)}
|
|
3557
|
+
` : renderLocks(snapshot);
|
|
3558
|
+
if (args.output) await writeFile(args.output, text);
|
|
3559
|
+
else process.stdout.write(text);
|
|
3560
|
+
return args.failOnBlocked && snapshot.blocked.length > 0 ? 1 /* CiGate */ : 0 /* Success */;
|
|
3561
|
+
}
|
|
3562
|
+
function renderLocks(live) {
|
|
3563
|
+
const c = colors();
|
|
3564
|
+
const out = [
|
|
3565
|
+
c.bold("Live locks"),
|
|
3566
|
+
c.dim(`${live.sessions.length} client session(s) \xB7 ${live.blocked.length} blocked`),
|
|
3567
|
+
""
|
|
3568
|
+
];
|
|
3569
|
+
if (live.blocked.length === 0) {
|
|
3570
|
+
out.push("No lock contention right now \u2014 nothing is waiting on another session.");
|
|
3571
|
+
return `${out.join("\n")}
|
|
3572
|
+
`;
|
|
3573
|
+
}
|
|
3574
|
+
for (const s of live.blocked) {
|
|
3575
|
+
const age = s.ageSeconds != null ? ` \xB7 waiting ${s.ageSeconds.toFixed(0)}s` : "";
|
|
3576
|
+
const wait = s.waitEvent ? ` \xB7 ${s.waitEventType ?? "?"}/${s.waitEvent}` : "";
|
|
3577
|
+
out.push(
|
|
3578
|
+
`${c.yellow("\u26A0")} pid ${c.bold(String(s.pid))} (${s.user ?? "?"}) blocked by pid ${s.blockedBy.join(", ")}${age}${wait}`
|
|
3579
|
+
);
|
|
3580
|
+
if (s.query) out.push(c.dim(` ${s.query.replace(/\s+/g, " ").slice(0, 200)}`));
|
|
3581
|
+
out.push(
|
|
3582
|
+
c.dim(
|
|
3583
|
+
` inspect the blocker; cancel with SELECT pg_cancel_backend(${s.blockedBy[0]}); or terminate with pg_terminate_backend(\u2026).`
|
|
3584
|
+
),
|
|
3585
|
+
""
|
|
3586
|
+
);
|
|
3587
|
+
}
|
|
3588
|
+
return `${out.join("\n")}
|
|
3589
|
+
`;
|
|
3590
|
+
}
|
|
3591
|
+
|
|
3592
|
+
// src/server/schema.ts
|
|
3593
|
+
var SQL2 = `
|
|
3594
|
+
SELECT c.relname AS relation,
|
|
3595
|
+
c.reltuples::bigint AS "estRows",
|
|
3596
|
+
pg_total_relation_size(c.oid) AS "totalBytes",
|
|
3597
|
+
pg_relation_size(c.oid) AS "tableBytes",
|
|
3598
|
+
(SELECT array_agg(ir.relname::text ORDER BY ir.relname)
|
|
3599
|
+
FROM pg_index i JOIN pg_class ir ON ir.oid = i.indexrelid
|
|
3600
|
+
WHERE i.indrelid = c.oid) AS indexes,
|
|
3601
|
+
s.last_vacuum AS "lastVacuum",
|
|
3602
|
+
s.last_autovacuum AS "lastAutovacuum",
|
|
3603
|
+
s.last_analyze AS "lastAnalyze",
|
|
3604
|
+
s.last_autoanalyze AS "lastAutoanalyze",
|
|
3605
|
+
s.n_mod_since_analyze AS "modSinceAnalyze",
|
|
3606
|
+
s.n_live_tup AS "liveTup"
|
|
3607
|
+
FROM pg_class c
|
|
3608
|
+
LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
|
|
3609
|
+
WHERE c.relkind IN ('r', 'p') AND c.relname = ANY($1);
|
|
3610
|
+
`;
|
|
3611
|
+
var toNum = (v) => v == null ? null : Number(v);
|
|
3612
|
+
async function relationStats(connection, relations) {
|
|
3613
|
+
const names = [...new Set(relations.filter(Boolean))];
|
|
3614
|
+
if (names.length === 0) return [];
|
|
3615
|
+
const rows = await queryReadOnly(connection, SQL2, [names]);
|
|
3616
|
+
return rows.map((r) => ({
|
|
3617
|
+
relation: r.relation,
|
|
3618
|
+
estRows: toNum(r.estRows),
|
|
3619
|
+
totalBytes: toNum(r.totalBytes),
|
|
3620
|
+
tableBytes: toNum(r.tableBytes),
|
|
3621
|
+
indexes: r.indexes ?? [],
|
|
3622
|
+
lastVacuum: r.lastVacuum,
|
|
3623
|
+
lastAutovacuum: r.lastAutovacuum,
|
|
3624
|
+
lastAnalyze: r.lastAnalyze,
|
|
3625
|
+
lastAutoanalyze: r.lastAutoanalyze,
|
|
3626
|
+
modSinceAnalyze: toNum(r.modSinceAnalyze),
|
|
3627
|
+
liveTup: toNum(r.liveTup)
|
|
3628
|
+
}));
|
|
3629
|
+
}
|
|
3630
|
+
|
|
3631
|
+
// src/diagnostics/stale-stats.ts
|
|
3632
|
+
var RULE_ID = "PGX_STALE_STATISTICS";
|
|
3633
|
+
var DOCS4 = "https://www.postgresql.org/docs/current/routine-vacuuming.html#VACUUM-FOR-STATISTICS";
|
|
3634
|
+
var MIN_ROWS = 1e3;
|
|
3635
|
+
function staleStatsFindings(stats, config) {
|
|
3636
|
+
if (config.rules[RULE_ID]?.enabled === false) return [];
|
|
3637
|
+
const severity = config.rules[RULE_ID]?.severity ?? "warn";
|
|
3638
|
+
const ratioLimit = config.thresholds.staleStatsModRatio;
|
|
3639
|
+
const out = [];
|
|
3640
|
+
for (const s of stats) {
|
|
3641
|
+
const rows = s.liveTup ?? s.estRows ?? 0;
|
|
3642
|
+
if (rows < MIN_ROWS) continue;
|
|
3643
|
+
const neverAnalyzed = !s.lastAnalyze && !s.lastAutoanalyze;
|
|
3644
|
+
const modRatio = s.modSinceAnalyze != null && rows > 0 ? s.modSinceAnalyze / rows : 0;
|
|
3645
|
+
if (!neverAnalyzed && modRatio < ratioLimit) continue;
|
|
3646
|
+
out.push(
|
|
3647
|
+
finding(RULE_ID, severity, {
|
|
3648
|
+
title: neverAnalyzed ? `Table ${s.relation} has never been analyzed` : `Planner statistics on ${s.relation} are stale`,
|
|
3649
|
+
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).`,
|
|
3650
|
+
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.",
|
|
3651
|
+
remediation: {
|
|
3652
|
+
summary: `Run ANALYZE on ${s.relation}, and if it keeps going stale, lower its autovacuum analyze threshold.`,
|
|
3653
|
+
steps: [
|
|
3654
|
+
"ANALYZE the table now to refresh statistics.",
|
|
3655
|
+
"If the table churns heavily, tune per-table autovacuum settings so auto-analyze keeps up."
|
|
3656
|
+
],
|
|
3657
|
+
commands: [
|
|
3658
|
+
{ label: "Refresh statistics", sql: `ANALYZE ${s.relation};` },
|
|
3659
|
+
{
|
|
3660
|
+
label: "Analyze more eagerly on churny tables",
|
|
3661
|
+
sql: `ALTER TABLE ${s.relation} SET (autovacuum_analyze_scale_factor = 0.02);`
|
|
3662
|
+
}
|
|
3663
|
+
]
|
|
3664
|
+
},
|
|
3665
|
+
docsUrl: DOCS4,
|
|
3666
|
+
meta: {
|
|
3667
|
+
relation: s.relation,
|
|
3668
|
+
modSinceAnalyze: s.modSinceAnalyze ?? 0,
|
|
3669
|
+
liveTup: s.liveTup ?? 0
|
|
3670
|
+
}
|
|
3671
|
+
})
|
|
3672
|
+
);
|
|
3673
|
+
}
|
|
3674
|
+
return out;
|
|
3675
|
+
}
|
|
3676
|
+
async function checkStaleStats(connection, result, config) {
|
|
3677
|
+
try {
|
|
3678
|
+
const relations = [
|
|
3679
|
+
...new Set(
|
|
3680
|
+
flatten(result.tree.root).map((n) => n.relationName).filter((r) => !!r)
|
|
3681
|
+
)
|
|
3682
|
+
];
|
|
3683
|
+
if (!relations.length) return;
|
|
3684
|
+
appendFindings(result, staleStatsFindings(await relationStats(connection, relations), config));
|
|
3685
|
+
} catch {
|
|
3686
|
+
}
|
|
3687
|
+
}
|
|
3688
|
+
function appendFindings(result, extra) {
|
|
3689
|
+
if (!extra.length) return;
|
|
3690
|
+
result.diagnostics = [...result.diagnostics, ...extra].sort(bySeverity);
|
|
3691
|
+
result.worstSeverity = result.diagnostics.reduce(
|
|
3692
|
+
(worst, d) => worst === null ? d.severity : maxSeverity(worst, d.severity),
|
|
3693
|
+
null
|
|
3694
|
+
);
|
|
3695
|
+
}
|
|
3696
|
+
|
|
3697
|
+
// src/sql/extract.ts
|
|
3698
|
+
var DML = /* @__PURE__ */ new Set(["SELECT", "INSERT", "UPDATE", "DELETE", "MERGE", "VALUES", "TABLE", "WITH"]);
|
|
3699
|
+
function classifyStatement(sql) {
|
|
3700
|
+
const kw = firstKeyword(sql);
|
|
3701
|
+
if (!kw) return "empty";
|
|
3702
|
+
if (kw === "DO") return "do-block";
|
|
3703
|
+
if (DML.has(kw) || kw === "EXECUTE") return "explainable";
|
|
3704
|
+
return "utility";
|
|
3705
|
+
}
|
|
3706
|
+
function extractAnalyzableUnits(sql) {
|
|
3707
|
+
const units = [];
|
|
3708
|
+
for (const stmt of splitStatements(sql)) {
|
|
3709
|
+
const cls = classifyStatement(stmt);
|
|
3710
|
+
if (cls === "empty") continue;
|
|
3711
|
+
if (cls === "explainable") {
|
|
3712
|
+
units.push({ kind: "explainable", label: unitLabel(stmt), sql: stmt });
|
|
3713
|
+
} else if (cls === "do-block") {
|
|
3714
|
+
units.push(...extractFromDo(stmt));
|
|
3715
|
+
} else {
|
|
3716
|
+
const kw = firstKeyword(stmt);
|
|
3717
|
+
units.push({
|
|
3718
|
+
kind: "skipped",
|
|
3719
|
+
label: `${kw} \u2026`,
|
|
3720
|
+
reason: `EXPLAIN cannot analyze a ${kw} statement (it is a utility/transaction-control command, not an optimizable query).`
|
|
3721
|
+
});
|
|
3722
|
+
}
|
|
3723
|
+
}
|
|
3724
|
+
return units;
|
|
3725
|
+
}
|
|
3726
|
+
function extractFromDo(doSql) {
|
|
3727
|
+
const body = dollarBody(doSql);
|
|
3728
|
+
if (body === null) {
|
|
3729
|
+
return [
|
|
3730
|
+
{ kind: "skipped", label: "DO block", reason: "Could not find the block body ($$ \u2026 $$)." }
|
|
3731
|
+
];
|
|
3732
|
+
}
|
|
3733
|
+
const out = [];
|
|
3734
|
+
for (const frag of splitStatements(body)) {
|
|
3735
|
+
const stripped = stripControl(frag);
|
|
3736
|
+
if (!stripped) continue;
|
|
3737
|
+
const kw = firstKeyword(stripped.sql);
|
|
3738
|
+
if (kw === "EXECUTE") {
|
|
3739
|
+
out.push({
|
|
3740
|
+
kind: "skipped",
|
|
3741
|
+
label: `${stripped.context}EXECUTE (dynamic SQL)`,
|
|
3742
|
+
reason: "Dynamic SQL built at runtime \u2014 the statement text isn't known statically, so it can't be analyzed."
|
|
3743
|
+
});
|
|
3744
|
+
continue;
|
|
3745
|
+
}
|
|
3746
|
+
if (DML.has(kw)) {
|
|
3747
|
+
const unit = {
|
|
3748
|
+
kind: "explainable",
|
|
3749
|
+
label: stripped.context + unitLabel(stripped.sql),
|
|
3750
|
+
sql: stripped.sql
|
|
3751
|
+
};
|
|
3752
|
+
if (stripped.loop) unit.loopNote = "runs once per loop iteration in the block";
|
|
3753
|
+
out.push(unit);
|
|
3754
|
+
}
|
|
3755
|
+
}
|
|
3756
|
+
if (out.length === 0) {
|
|
3757
|
+
out.push({
|
|
3758
|
+
kind: "skipped",
|
|
3759
|
+
label: "DO block",
|
|
3760
|
+
reason: "No top-level DML statements found to analyze."
|
|
3761
|
+
});
|
|
3762
|
+
}
|
|
3763
|
+
return out;
|
|
3764
|
+
}
|
|
3765
|
+
function stripControl(frag) {
|
|
3766
|
+
let rest = frag;
|
|
3767
|
+
let context = "";
|
|
3768
|
+
let loop = false;
|
|
3769
|
+
for (let guard = 0; guard < 8; guard++) {
|
|
3770
|
+
rest = rest.replace(/^\s+/, "");
|
|
3771
|
+
const masked = maskNonCode(rest);
|
|
3772
|
+
const kw2 = (/^[A-Za-z_]+/.exec(masked)?.[0] ?? "").toUpperCase();
|
|
3773
|
+
if (kw2 === "IF" || kw2 === "ELSIF") {
|
|
3774
|
+
const then = /\bTHEN\b/i.exec(masked);
|
|
3775
|
+
if (!then) break;
|
|
3776
|
+
context += kw2 === "IF" ? "IF-branch \u203A " : "ELSIF-branch \u203A ";
|
|
3777
|
+
rest = rest.slice(then.index + 4);
|
|
3778
|
+
} else if (kw2 === "ELSE") {
|
|
3779
|
+
context += "ELSE-branch \u203A ";
|
|
3780
|
+
rest = rest.replace(/^\s*ELSE\b/i, "");
|
|
3781
|
+
} else if (kw2 === "FOR" || kw2 === "WHILE") {
|
|
3782
|
+
const lp = /\bLOOP\b/i.exec(masked);
|
|
3783
|
+
if (!lp) break;
|
|
3784
|
+
loop = true;
|
|
3785
|
+
context += "loop \u203A ";
|
|
3786
|
+
rest = rest.slice(lp.index + 4);
|
|
3787
|
+
} else if (kw2 === "LOOP") {
|
|
3788
|
+
loop = true;
|
|
3789
|
+
context += "loop \u203A ";
|
|
3790
|
+
rest = rest.replace(/^\s*LOOP\b/i, "");
|
|
3791
|
+
} else if (kw2 === "BEGIN" || kw2 === "THEN") {
|
|
3792
|
+
rest = rest.replace(/^\s*(BEGIN|THEN)\b/i, "");
|
|
3793
|
+
} else {
|
|
3794
|
+
break;
|
|
3795
|
+
}
|
|
3796
|
+
}
|
|
3797
|
+
rest = rest.replace(/^\s+/, "").replace(/;\s*$/, "").trim();
|
|
3798
|
+
if (!rest) return null;
|
|
3799
|
+
const kw = firstKeyword(rest);
|
|
3800
|
+
if (kw === "PERFORM") return { context, sql: rest.replace(/^\s*PERFORM\b/i, "SELECT"), loop };
|
|
3801
|
+
if (DML.has(kw) || kw === "EXECUTE") return { context, sql: rest, loop };
|
|
3802
|
+
return null;
|
|
3803
|
+
}
|
|
3804
|
+
function firstKeyword(sql) {
|
|
3805
|
+
const m = /^[A-Za-z_]+/.exec(maskNonCode(sql).trim());
|
|
3806
|
+
return m ? m[0].toUpperCase() : "";
|
|
3807
|
+
}
|
|
3808
|
+
function unitLabel(stmt) {
|
|
3809
|
+
const kw = firstKeyword(stmt);
|
|
3810
|
+
const t = targetTable2(stmt, kw);
|
|
3811
|
+
return t ? `${kw} ${t}` : kw || "statement";
|
|
3812
|
+
}
|
|
3813
|
+
function targetTable2(stmt, kw) {
|
|
3814
|
+
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;
|
|
3815
|
+
return re ? re.exec(stmt)?.[1] ?? void 0 : void 0;
|
|
3816
|
+
}
|
|
3817
|
+
function dollarBody(doSql) {
|
|
3818
|
+
const m = /\$([A-Za-z_]*)\$/.exec(doSql);
|
|
3819
|
+
if (!m) return null;
|
|
3820
|
+
const tag = m[0];
|
|
3821
|
+
const start = m.index + tag.length;
|
|
3822
|
+
const end = doSql.indexOf(tag, start);
|
|
3823
|
+
return end < 0 ? null : doSql.slice(start, end);
|
|
3824
|
+
}
|
|
3825
|
+
function maskNonCode(sql) {
|
|
3826
|
+
const out = sql.split("");
|
|
3827
|
+
const n = sql.length;
|
|
3828
|
+
let i = 0;
|
|
3829
|
+
const blank = (a, b) => {
|
|
3830
|
+
for (let k = a; k < b && k < n; k++) if (out[k] !== "\n") out[k] = " ";
|
|
3831
|
+
};
|
|
3832
|
+
while (i < n) {
|
|
3833
|
+
const two = sql.slice(i, i + 2);
|
|
3834
|
+
if (two === "--") {
|
|
3835
|
+
let j = sql.indexOf("\n", i);
|
|
3836
|
+
if (j < 0) j = n;
|
|
3837
|
+
blank(i, j);
|
|
3838
|
+
i = j;
|
|
3839
|
+
} else if (two === "/*") {
|
|
3840
|
+
let j = sql.indexOf("*/", i + 2);
|
|
3841
|
+
j = j < 0 ? n : j + 2;
|
|
3842
|
+
blank(i, j);
|
|
3843
|
+
i = j;
|
|
3844
|
+
} else if (sql[i] === "'" || sql[i] === '"') {
|
|
3845
|
+
const q = sql[i];
|
|
3846
|
+
let j = i + 1;
|
|
3847
|
+
while (j < n) {
|
|
3848
|
+
if (sql[j] === q) {
|
|
3849
|
+
if (sql[j + 1] === q) j += 2;
|
|
3850
|
+
else {
|
|
3851
|
+
j++;
|
|
3852
|
+
break;
|
|
3853
|
+
}
|
|
3854
|
+
} else j++;
|
|
3855
|
+
}
|
|
3856
|
+
blank(i, j);
|
|
3857
|
+
i = j;
|
|
3858
|
+
} else if (sql[i] === "$") {
|
|
3859
|
+
const m = /^\$[A-Za-z_]*\$/.exec(sql.slice(i));
|
|
3860
|
+
if (m) {
|
|
3861
|
+
const tag = m[0];
|
|
3862
|
+
let j = sql.indexOf(tag, i + tag.length);
|
|
3863
|
+
j = j < 0 ? n : j + tag.length;
|
|
3864
|
+
blank(i, j);
|
|
3865
|
+
i = j;
|
|
3866
|
+
} else i++;
|
|
3867
|
+
} else i++;
|
|
3868
|
+
}
|
|
3869
|
+
return out.join("");
|
|
3870
|
+
}
|
|
3871
|
+
async function analyzeScript(connection, sql, opts) {
|
|
3872
|
+
const units = extractAnalyzableUnits(sql);
|
|
3873
|
+
const explainable = units.filter((u) => u.kind === "explainable");
|
|
3874
|
+
const exec = explainable.length ? await explainScript(
|
|
3875
|
+
connection,
|
|
3876
|
+
explainable.map((u) => ({
|
|
3877
|
+
label: u.label,
|
|
3878
|
+
sql: u.sql,
|
|
3879
|
+
...u.loopNote ? { loopNote: u.loopNote } : {}
|
|
3880
|
+
})),
|
|
3881
|
+
{
|
|
3882
|
+
statementTimeoutMs: opts.statementTimeoutMs,
|
|
3883
|
+
lockTimeoutMs: opts.lockTimeoutMs,
|
|
3884
|
+
verbose: opts.verbose,
|
|
3885
|
+
settings: opts.settings
|
|
3886
|
+
}
|
|
3887
|
+
) : null;
|
|
3888
|
+
let ei = 0;
|
|
3889
|
+
const out = units.map((u) => {
|
|
3890
|
+
if (u.kind === "skipped") return { label: u.label, status: "skipped", reason: u.reason };
|
|
3891
|
+
const r = exec?.units[ei++];
|
|
3892
|
+
if (!r) return { label: u.label, status: "skipped", reason: "not analyzed" };
|
|
3893
|
+
if (r.error) {
|
|
3894
|
+
return {
|
|
3895
|
+
label: u.label,
|
|
3896
|
+
status: "error",
|
|
3897
|
+
reason: r.error.detail,
|
|
3898
|
+
errorCode: r.error.code,
|
|
3899
|
+
...r.loopNote ? { loopNote: r.loopNote } : {}
|
|
3900
|
+
};
|
|
3901
|
+
}
|
|
3902
|
+
const result = analyze(r.planJson, {
|
|
3903
|
+
sql: u.sql,
|
|
3904
|
+
config: opts.config,
|
|
3905
|
+
redact: opts.redact
|
|
3906
|
+
});
|
|
3907
|
+
return {
|
|
3908
|
+
label: u.label,
|
|
3909
|
+
status: "analyzed",
|
|
3910
|
+
result,
|
|
3911
|
+
report: buildReport(result),
|
|
3912
|
+
...r.loopNote ? { loopNote: r.loopNote } : {}
|
|
3913
|
+
};
|
|
3914
|
+
});
|
|
3915
|
+
return { executed: false, units: out, ...exec ? { serverMajor: exec.caps.major } : {} };
|
|
3916
|
+
}
|
|
3917
|
+
async function emitScript(analysis, opts) {
|
|
3918
|
+
configureColor(opts.format === "terminal" ? opts.color : "never");
|
|
3919
|
+
const text = opts.format === "json" ? renderJsonScript(analysis, opts.pretty ?? true) : renderTextScript(analysis, opts);
|
|
3920
|
+
if (opts.output) await writeFile(opts.output, text);
|
|
3921
|
+
else process.stdout.write(text.endsWith("\n") ? text : `${text}
|
|
3922
|
+
`);
|
|
3923
|
+
if (opts.failOn) {
|
|
3924
|
+
for (const u of analysis.units) {
|
|
3925
|
+
if (u.result?.worstSeverity && severityAtLeast(u.result.worstSeverity, opts.failOn))
|
|
3926
|
+
return 1 /* CiGate */;
|
|
3927
|
+
}
|
|
3928
|
+
}
|
|
3929
|
+
return 0 /* Success */;
|
|
3930
|
+
}
|
|
3931
|
+
function renderJsonScript(analysis, pretty) {
|
|
3932
|
+
const units = analysis.units.map((u) => ({
|
|
3933
|
+
label: u.label,
|
|
3934
|
+
status: u.status,
|
|
3935
|
+
loopNote: u.loopNote ?? null,
|
|
3936
|
+
report: u.report ?? null,
|
|
3937
|
+
reason: u.reason ?? null,
|
|
3938
|
+
errorCode: u.errorCode ?? null
|
|
3939
|
+
}));
|
|
3940
|
+
return JSON.stringify(
|
|
3941
|
+
{ executed: false, serverMajor: analysis.serverMajor ?? null, units },
|
|
3942
|
+
null,
|
|
3943
|
+
pretty ? 2 : 0
|
|
3944
|
+
);
|
|
3945
|
+
}
|
|
3946
|
+
function renderTextScript(analysis, opts) {
|
|
3947
|
+
const c = colors();
|
|
3948
|
+
const analyzed = analysis.units.filter((u) => u.status === "analyzed").length;
|
|
3949
|
+
const skipped = analysis.units.length - analyzed;
|
|
3950
|
+
const out = [];
|
|
3951
|
+
out.push(
|
|
3952
|
+
c.bold("Cost-only analysis \u2014 nothing was executed.") + c.dim(` ${analyzed} analyzed, ${skipped} skipped/failed.`)
|
|
3953
|
+
);
|
|
3954
|
+
out.push("");
|
|
3955
|
+
for (const u of analysis.units) {
|
|
3956
|
+
out.push(c.bold(`\u25B8 ${u.label}`) + (u.loopNote ? c.dim(` (${u.loopNote})`) : ""));
|
|
3957
|
+
if (u.status === "analyzed" && u.result) {
|
|
3958
|
+
out.push(render(u.result, { format: opts.format, tldr: opts.tldr, ascii: opts.ascii }));
|
|
3959
|
+
} else {
|
|
3960
|
+
const tag = u.status === "error" ? c.yellow("could not analyze") : c.dim("skipped");
|
|
3961
|
+
out.push(
|
|
3962
|
+
` ${tag}: ${u.reason ?? "(no detail)"}${u.errorCode ? c.dim(` [${u.errorCode}]`) : ""}`
|
|
3963
|
+
);
|
|
3964
|
+
}
|
|
3965
|
+
out.push("");
|
|
3966
|
+
}
|
|
3967
|
+
return out.join("\n").trimEnd();
|
|
3968
|
+
}
|
|
3969
|
+
|
|
2814
3970
|
// src/commands/run.ts
|
|
2815
3971
|
async function runRun(args) {
|
|
2816
|
-
const
|
|
2817
|
-
const
|
|
2818
|
-
const
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
3972
|
+
const fullSql = await resolveSql(args);
|
|
3973
|
+
const sql = args.statementIndex !== void 0 ? selectStatement2(splitStatements(fullSql), args.statementIndex) : fullSql;
|
|
3974
|
+
const units = extractAnalyzableUnits(sql);
|
|
3975
|
+
const single = units.length === 1 && units[0]?.kind === "explainable" ? units[0] : null;
|
|
3976
|
+
const measured = single?.kind === "explainable" && args.flags.analyze && !args.flags.genericPlan && (isReadOnlyStatement(single.sql) || args.forceWrite);
|
|
3977
|
+
if (measured && single) {
|
|
3978
|
+
const result = await runExplain({
|
|
3979
|
+
connection: args.connection,
|
|
3980
|
+
statement: single.sql,
|
|
3981
|
+
params: args.params,
|
|
3982
|
+
flags: args.flags,
|
|
3983
|
+
statementTimeoutMs: args.statementTimeoutMs,
|
|
3984
|
+
lockTimeoutMs: args.lockTimeoutMs,
|
|
3985
|
+
forceWrite: args.forceWrite,
|
|
3986
|
+
rollback: args.rollback
|
|
2823
3987
|
});
|
|
3988
|
+
if (result.omitted.length) {
|
|
3989
|
+
logInfo(
|
|
3990
|
+
`Note: server is PostgreSQL ${result.caps.major}; skipped unsupported option(s): ${result.omitted.join(", ")}.`
|
|
3991
|
+
);
|
|
3992
|
+
}
|
|
3993
|
+
const analysis2 = analyze(result.json, {
|
|
3994
|
+
config: args.config,
|
|
3995
|
+
redact: args.redact,
|
|
3996
|
+
sql: single.sql
|
|
3997
|
+
});
|
|
3998
|
+
await checkStaleStats(args.connection, analysis2, args.config);
|
|
3999
|
+
return emit(analysis2, args);
|
|
2824
4000
|
}
|
|
2825
|
-
const
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
params: args.params,
|
|
2829
|
-
flags: args.flags,
|
|
4001
|
+
const analysis = await analyzeScript(args.connection, sql, {
|
|
4002
|
+
config: args.config,
|
|
4003
|
+
redact: args.redact,
|
|
2830
4004
|
statementTimeoutMs: args.statementTimeoutMs,
|
|
2831
4005
|
lockTimeoutMs: args.lockTimeoutMs,
|
|
2832
|
-
|
|
2833
|
-
|
|
4006
|
+
verbose: args.flags.verbose,
|
|
4007
|
+
settings: args.flags.settings
|
|
4008
|
+
});
|
|
4009
|
+
return emitScript(analysis, {
|
|
4010
|
+
format: args.format,
|
|
4011
|
+
output: args.output,
|
|
4012
|
+
color: args.color,
|
|
4013
|
+
ascii: args.ascii,
|
|
4014
|
+
tldr: args.tldr,
|
|
4015
|
+
pretty: args.pretty,
|
|
4016
|
+
failOn: args.failOn
|
|
2834
4017
|
});
|
|
2835
|
-
if (result.omitted.length) {
|
|
2836
|
-
logInfo(
|
|
2837
|
-
`Note: server is PostgreSQL ${result.caps.major}; skipped unsupported option(s): ${result.omitted.join(", ")}.`
|
|
2838
|
-
);
|
|
2839
|
-
}
|
|
2840
|
-
const analysis = analyze(result.json, { config: args.config, redact: args.redact });
|
|
2841
|
-
return emit(analysis, args);
|
|
2842
4018
|
}
|
|
2843
4019
|
async function resolveSql(args) {
|
|
2844
4020
|
if (args.query) return args.query;
|
|
@@ -2880,6 +4056,35 @@ function selectStatement2(statements, index) {
|
|
|
2880
4056
|
return statements[0];
|
|
2881
4057
|
}
|
|
2882
4058
|
|
|
4059
|
+
// src/commands/studio.ts
|
|
4060
|
+
async function runStudio(args) {
|
|
4061
|
+
const loopback = args.host === "127.0.0.1" || args.host === "localhost" || args.host === "::1";
|
|
4062
|
+
if (!loopback && !args.unsafeHost) {
|
|
4063
|
+
logInfo(
|
|
4064
|
+
`Refusing to bind ${args.host}: the studio can connect to arbitrary databases, so exposing it off-loopback is an SSRF/credential risk. Pass --unsafe-host to override.`
|
|
4065
|
+
);
|
|
4066
|
+
return 2 /* Usage */;
|
|
4067
|
+
}
|
|
4068
|
+
const mod = await import(new URL("./server.js", import.meta.url).href);
|
|
4069
|
+
const server = await mod.startStudio({ host: args.host, port: args.port });
|
|
4070
|
+
logInfo(`
|
|
4071
|
+
pgexplain studio ${server.url}
|
|
4072
|
+
Press Ctrl-C to stop.
|
|
4073
|
+
`);
|
|
4074
|
+
if (args.open) openInBrowser(server.url);
|
|
4075
|
+
process.removeAllListeners("SIGINT");
|
|
4076
|
+
process.removeAllListeners("SIGTERM");
|
|
4077
|
+
await new Promise((resolve) => {
|
|
4078
|
+
const stop = () => {
|
|
4079
|
+
logInfo("\nShutting down\u2026");
|
|
4080
|
+
server.close().finally(resolve);
|
|
4081
|
+
};
|
|
4082
|
+
process.once("SIGINT", stop);
|
|
4083
|
+
process.once("SIGTERM", stop);
|
|
4084
|
+
});
|
|
4085
|
+
return 0 /* Success */;
|
|
4086
|
+
}
|
|
4087
|
+
|
|
2883
4088
|
// src/diagnostics/print.ts
|
|
2884
4089
|
function formatDiagnostic(d) {
|
|
2885
4090
|
const c = colors();
|
|
@@ -2915,6 +4120,11 @@ var outputArgs = {
|
|
|
2915
4120
|
},
|
|
2916
4121
|
tldr: { type: "boolean", description: "Summary + findings only (no plan tree)" },
|
|
2917
4122
|
redact: { type: "boolean", description: "Strip literal values from expressions (safe to share)" },
|
|
4123
|
+
open: {
|
|
4124
|
+
type: "boolean",
|
|
4125
|
+
description: "Open the HTML report in your browser (default: on when interactive)"
|
|
4126
|
+
},
|
|
4127
|
+
"no-open": { type: "boolean", description: "Never open the HTML report in the browser" },
|
|
2918
4128
|
ascii: { type: "boolean", description: "Use ASCII tree glyphs instead of Unicode" },
|
|
2919
4129
|
color: { type: "string", default: "auto", description: "auto | always | never" },
|
|
2920
4130
|
"no-color": { type: "boolean", description: "Disable color (same as --color never)" },
|
|
@@ -2948,6 +4158,7 @@ function emitOptionsFrom(args) {
|
|
|
2948
4158
|
if (args.output) opts.output = args.output;
|
|
2949
4159
|
const failOn = resolveFailOn(args);
|
|
2950
4160
|
if (failOn) opts.failOn = failOn;
|
|
4161
|
+
opts.openHtml = args["no-open"] ? false : args.open ? true : Boolean(process.stdout.isTTY) && !process.env.CI;
|
|
2951
4162
|
return opts;
|
|
2952
4163
|
}
|
|
2953
4164
|
function resolveFormat(value) {
|
|
@@ -3157,6 +4368,100 @@ var diffCmd = defineCommand({
|
|
|
3157
4368
|
}
|
|
3158
4369
|
}
|
|
3159
4370
|
});
|
|
4371
|
+
var locksCmd = defineCommand({
|
|
4372
|
+
meta: {
|
|
4373
|
+
name: "locks",
|
|
4374
|
+
description: "Snapshot live lock contention: who is blocked, and by whom."
|
|
4375
|
+
},
|
|
4376
|
+
args: {
|
|
4377
|
+
dsn: {
|
|
4378
|
+
type: "string",
|
|
4379
|
+
description: "Connection string (or use --host/--port/\u2026 or PG* env vars)"
|
|
4380
|
+
},
|
|
4381
|
+
host: { type: "string", description: "Server host" },
|
|
4382
|
+
port: { type: "string", description: "Server port" },
|
|
4383
|
+
dbname: { type: "string", alias: "d", description: "Database name" },
|
|
4384
|
+
user: { type: "string", alias: "U", description: "Role name" },
|
|
4385
|
+
sslmode: { type: "string", description: "disable | require | verify-ca | verify-full" },
|
|
4386
|
+
sslrootcert: { type: "string", description: "Path to a CA certificate (PEM)" },
|
|
4387
|
+
"connect-timeout": {
|
|
4388
|
+
type: "string",
|
|
4389
|
+
default: "10s",
|
|
4390
|
+
description: "Connection timeout (e.g. 30s)"
|
|
4391
|
+
},
|
|
4392
|
+
format: { type: "string", default: "terminal", alias: "f", description: "terminal | json" },
|
|
4393
|
+
output: { type: "string", alias: "o", description: "Write to a file instead of stdout" },
|
|
4394
|
+
color: { type: "string", default: "auto", description: "auto | always | never" },
|
|
4395
|
+
"no-color": { type: "boolean", description: "Disable color" },
|
|
4396
|
+
"fail-on-blocked": {
|
|
4397
|
+
type: "boolean",
|
|
4398
|
+
description: "Exit 1 if any session is currently blocked"
|
|
4399
|
+
},
|
|
4400
|
+
quiet: { type: "boolean", alias: "q", description: "Suppress non-error logs" },
|
|
4401
|
+
debug: { type: "boolean", description: "Print stack traces on internal errors" }
|
|
4402
|
+
},
|
|
4403
|
+
async run({ args }) {
|
|
4404
|
+
try {
|
|
4405
|
+
applyGlobalFlags(args);
|
|
4406
|
+
if (!["terminal", "json"].includes(args.format)) {
|
|
4407
|
+
throw usageError(`Unknown locks --format '${args.format}'`, "Use terminal or json.");
|
|
4408
|
+
}
|
|
4409
|
+
const connection = {
|
|
4410
|
+
connectTimeoutMs: parseDurationMs(args["connect-timeout"])
|
|
4411
|
+
};
|
|
4412
|
+
if (args.dsn) connection.dsn = args.dsn;
|
|
4413
|
+
if (args.host) connection.host = args.host;
|
|
4414
|
+
if (args.port) connection.port = Number(args.port);
|
|
4415
|
+
if (args.dbname) connection.database = args.dbname;
|
|
4416
|
+
if (args.user) connection.user = args.user;
|
|
4417
|
+
if (args.sslmode) connection.sslmode = args.sslmode;
|
|
4418
|
+
if (args.sslrootcert) connection.sslrootcert = args.sslrootcert;
|
|
4419
|
+
process.exitCode = await runLocks({
|
|
4420
|
+
connection,
|
|
4421
|
+
format: args.format,
|
|
4422
|
+
output: args.output,
|
|
4423
|
+
color: args["no-color"] ? "never" : args.color,
|
|
4424
|
+
failOnBlocked: !!args["fail-on-blocked"]
|
|
4425
|
+
});
|
|
4426
|
+
} catch (err) {
|
|
4427
|
+
process.exitCode = handleFatal(err);
|
|
4428
|
+
}
|
|
4429
|
+
}
|
|
4430
|
+
});
|
|
4431
|
+
var studioCmd = defineCommand({
|
|
4432
|
+
meta: { name: "studio", description: "Launch the local pgexplain Studio web app." },
|
|
4433
|
+
args: {
|
|
4434
|
+
port: { type: "string", default: "5177", description: "Port to listen on" },
|
|
4435
|
+
host: {
|
|
4436
|
+
type: "string",
|
|
4437
|
+
default: "127.0.0.1",
|
|
4438
|
+
description: "Host to bind (loopback only unless --unsafe-host)"
|
|
4439
|
+
},
|
|
4440
|
+
"no-open": { type: "boolean", description: "Do not open the browser automatically" },
|
|
4441
|
+
"unsafe-host": {
|
|
4442
|
+
type: "boolean",
|
|
4443
|
+
description: "Allow binding a non-loopback host (SSRF/credential risk)"
|
|
4444
|
+
},
|
|
4445
|
+
debug: { type: "boolean", description: "Print stack traces on internal errors" }
|
|
4446
|
+
},
|
|
4447
|
+
async run({ args }) {
|
|
4448
|
+
try {
|
|
4449
|
+
applyGlobalFlags(args);
|
|
4450
|
+
const port = Number(args.port);
|
|
4451
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
4452
|
+
throw usageError(`Invalid --port '${args.port}'`, "Use a port between 0 and 65535.");
|
|
4453
|
+
}
|
|
4454
|
+
process.exitCode = await runStudio({
|
|
4455
|
+
host: args.host,
|
|
4456
|
+
port,
|
|
4457
|
+
open: !args["no-open"],
|
|
4458
|
+
unsafeHost: !!args["unsafe-host"]
|
|
4459
|
+
});
|
|
4460
|
+
} catch (err) {
|
|
4461
|
+
process.exitCode = handleFatal(err);
|
|
4462
|
+
}
|
|
4463
|
+
}
|
|
4464
|
+
});
|
|
3160
4465
|
var main = defineCommand({
|
|
3161
4466
|
meta: {
|
|
3162
4467
|
name: "pg-explain",
|
|
@@ -3196,7 +4501,7 @@ var argv = process.argv.slice(2);
|
|
|
3196
4501
|
if (argv[0] === "completion") {
|
|
3197
4502
|
process.exitCode = runCompletion(argv[1]);
|
|
3198
4503
|
} else {
|
|
3199
|
-
const started = argv[0] === "run" ? runMain(runCmd, { rawArgs: argv.slice(1) }) : argv[0] === "diff" ? runMain(diffCmd, { rawArgs: argv.slice(1) }) : runMain(main, { rawArgs: argv });
|
|
4504
|
+
const started = argv[0] === "run" ? runMain(runCmd, { rawArgs: argv.slice(1) }) : argv[0] === "diff" ? runMain(diffCmd, { rawArgs: argv.slice(1) }) : argv[0] === "locks" ? runMain(locksCmd, { rawArgs: argv.slice(1) }) : argv[0] === "studio" ? runMain(studioCmd, { rawArgs: argv.slice(1) }) : runMain(main, { rawArgs: argv });
|
|
3200
4505
|
started.catch((err) => {
|
|
3201
4506
|
process.exitCode = handleFatal(err);
|
|
3202
4507
|
});
|