pgexplain 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -2
- package/dist/cli.js +1031 -75
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +30 -2
- package/dist/index.js +532 -30
- package/dist/index.js.map +1 -1
- package/dist/server.js +3996 -0
- package/dist/server.js.map +1 -0
- package/dist/web/assets/PlanGraph-0P3xGm2e.js +23 -0
- package/dist/web/assets/PlanGraph-C5ap-Sga.css +1 -0
- package/dist/web/assets/index-BqB6p5Pn.js +227 -0
- package/dist/web/assets/index-ByEFSLsN.css +1 -0
- package/dist/web/index.html +13 -0
- package/package.json +10 -1
package/dist/index.js
CHANGED
|
@@ -442,6 +442,308 @@ var DEFAULT_CONFIG = {
|
|
|
442
442
|
thresholds: { ...DEFAULT_THRESHOLDS },
|
|
443
443
|
rules: {}
|
|
444
444
|
};
|
|
445
|
+
|
|
446
|
+
// src/core/parse-text.ts
|
|
447
|
+
var cap = (w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase();
|
|
448
|
+
var numeric = (v) => {
|
|
449
|
+
const t = v.trim();
|
|
450
|
+
return /^-?\d+(\.\d+)?$/.test(t) ? Number(t) : t;
|
|
451
|
+
};
|
|
452
|
+
function splitList(s) {
|
|
453
|
+
const out = [];
|
|
454
|
+
let depth = 0;
|
|
455
|
+
let cur = "";
|
|
456
|
+
for (const ch of s) {
|
|
457
|
+
if (ch === "(") depth++;
|
|
458
|
+
else if (ch === ")") depth--;
|
|
459
|
+
if (ch === "," && depth === 0) {
|
|
460
|
+
if (cur.trim()) out.push(cur.trim());
|
|
461
|
+
cur = "";
|
|
462
|
+
} else {
|
|
463
|
+
cur += ch;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (cur.trim()) out.push(cur.trim());
|
|
467
|
+
return out;
|
|
468
|
+
}
|
|
469
|
+
function splitIntoLines(text) {
|
|
470
|
+
const out = [];
|
|
471
|
+
const lines = text.split(/\r?\n/);
|
|
472
|
+
const count = (s, re) => (s.match(re) || []).length;
|
|
473
|
+
const closingFirst = (s) => {
|
|
474
|
+
const c = s.indexOf(")");
|
|
475
|
+
const o = s.indexOf("(");
|
|
476
|
+
return c !== -1 && c < o;
|
|
477
|
+
};
|
|
478
|
+
const sameIndent = (a, b) => a.search(/\S/) === b.search(/\S/);
|
|
479
|
+
for (const line of lines) {
|
|
480
|
+
const prev = out[out.length - 1];
|
|
481
|
+
if (prev && count(prev, /\)/g) !== count(prev, /\(/g)) {
|
|
482
|
+
out[out.length - 1] += line;
|
|
483
|
+
} else if (/^(?:Total\s+runtime|Planning(\s+time)?|Execution\s+time|Time|Filter|Output|JIT|Trigger|Settings|Serialization)/i.test(
|
|
484
|
+
line
|
|
485
|
+
)) {
|
|
486
|
+
out.push(line);
|
|
487
|
+
} else if (/^\S/.test(line) || /^\s*\(/.test(line) || closingFirst(line)) {
|
|
488
|
+
if (prev) out[out.length - 1] += line;
|
|
489
|
+
else out.push(line);
|
|
490
|
+
} else if (prev && /,\s*$/.test(prev) && !sameIndent(prev, line) && !/^\s*->/i.test(line)) {
|
|
491
|
+
out[out.length - 1] += line;
|
|
492
|
+
} else {
|
|
493
|
+
out.push(line);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return out;
|
|
497
|
+
}
|
|
498
|
+
var estimation = String.raw`\(cost=(\d+\.\d+)\.\.(\d+\.\d+)\s+rows=(\d+)\s+width=(\d+)\)`;
|
|
499
|
+
var actual = String.raw`(?:actual(?:\stime=(\d+\.\d+)\.\.(\d+\.\d+))?\srows=(\d+(?:\.\d+)?)\sloops=(\d+)|(never\s+executed))`;
|
|
500
|
+
var nodeRe = new RegExp(
|
|
501
|
+
String.raw`^(\s*->\s*|\s*)(Finalize|Simple|Partial)*\s*([^\r\n\t\f\v(]*?)\s*` + String.raw`(?:(?:${estimation}\s+\(${actual}\))|(?:${estimation})|(?:\(${actual}\)))\s*$`
|
|
502
|
+
);
|
|
503
|
+
var subRe = /^((?:Sub|Init)Plan)\s*(?:\d+\s*)?(?:\(returns.*\))?\s*$/;
|
|
504
|
+
var cteRe = /^CTE\s+(\S+)\s*$/;
|
|
505
|
+
var workerRe = /^Worker\s+(\d+):\s+(?:actual(?:\stime=(\d+\.\d+)\.\.(\d+\.\d+))?\srows=(\d+(?:\.\d+)?)\sloops=(\d+)|never\s+executed)(.*)$/;
|
|
506
|
+
var triggerRe = /^Trigger\s+(.*):\s+time=(\d+\.\d+)\s+calls=(\d+)\s*$/;
|
|
507
|
+
var headerRe = /^(QUERY PLAN|-{2,}|#|\(\d+ rows?\))/;
|
|
508
|
+
function splitNodeType(text) {
|
|
509
|
+
let s = text.trim();
|
|
510
|
+
let indexName;
|
|
511
|
+
let relationName;
|
|
512
|
+
let schema;
|
|
513
|
+
let alias;
|
|
514
|
+
const using = s.match(/\susing (\S+)/);
|
|
515
|
+
if (using?.[1]) {
|
|
516
|
+
indexName = using[1];
|
|
517
|
+
s = s.replace(using[0] ?? "", "");
|
|
518
|
+
}
|
|
519
|
+
const on = s.match(/\son (\S+?)(?:\s+(\S+))?\s*$/);
|
|
520
|
+
if (on?.[1]) {
|
|
521
|
+
let rel = on[1];
|
|
522
|
+
alias = on[2];
|
|
523
|
+
const dot = rel.lastIndexOf(".");
|
|
524
|
+
if (dot !== -1) {
|
|
525
|
+
schema = rel.slice(0, dot);
|
|
526
|
+
rel = rel.slice(dot + 1);
|
|
527
|
+
}
|
|
528
|
+
relationName = rel;
|
|
529
|
+
s = s.slice(0, on.index).trim();
|
|
530
|
+
}
|
|
531
|
+
const nodeType = s.replace(/^Parallel\s+/, "").trim();
|
|
532
|
+
if (nodeType === "Bitmap Index Scan" && relationName && !indexName) {
|
|
533
|
+
indexName = relationName;
|
|
534
|
+
relationName = void 0;
|
|
535
|
+
schema = void 0;
|
|
536
|
+
alias = void 0;
|
|
537
|
+
}
|
|
538
|
+
const out = { "Node Type": nodeType };
|
|
539
|
+
if (relationName) out["Relation Name"] = relationName;
|
|
540
|
+
if (indexName) out["Index Name"] = indexName;
|
|
541
|
+
if (schema) out.Schema = schema;
|
|
542
|
+
if (alias && alias !== relationName) out.Alias = alias;
|
|
543
|
+
return out;
|
|
544
|
+
}
|
|
545
|
+
function parseSort(text, node) {
|
|
546
|
+
const m = text.match(/^Sort Method:\s+(.*?)\s+(Memory|Disk):\s+(\S+)kB\s*$/);
|
|
547
|
+
if (!m?.[1] || !m[2] || m[3] === void 0) return false;
|
|
548
|
+
node["Sort Method"] = m[1].trim();
|
|
549
|
+
node["Sort Space Type"] = m[2];
|
|
550
|
+
node["Sort Space Used"] = Number(m[3]);
|
|
551
|
+
return true;
|
|
552
|
+
}
|
|
553
|
+
function parseBuffers(text, node) {
|
|
554
|
+
const m = text.match(/^Buffers:\s+(.*)$/);
|
|
555
|
+
if (!m?.[1]) return false;
|
|
556
|
+
for (const group of m[1].split(/,\s+/)) {
|
|
557
|
+
const g = group.match(/^(shared|temp|local)\s+(.*)$/);
|
|
558
|
+
if (!g?.[1] || g[2] === void 0) continue;
|
|
559
|
+
const type = cap(g[1]);
|
|
560
|
+
for (const kv of g[2].trim().split(/\s+/)) {
|
|
561
|
+
const [method, value] = kv.split("=");
|
|
562
|
+
if (method && value !== void 0) node[`${type} ${cap(method)} Blocks`] = Number(value);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return true;
|
|
566
|
+
}
|
|
567
|
+
function parseWal(text, node) {
|
|
568
|
+
const m = text.match(/^WAL:\s+(.*)$/);
|
|
569
|
+
if (!m?.[1]) return false;
|
|
570
|
+
for (const kv of m[1].trim().split(/\s+/)) {
|
|
571
|
+
const [k, value] = kv.split("=");
|
|
572
|
+
if (!k || value === void 0) continue;
|
|
573
|
+
node[`WAL ${k === "fpi" ? "FPI" : cap(k)}`] = Number(value);
|
|
574
|
+
}
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
function parseIoTimings(text, node) {
|
|
578
|
+
const m = text.match(/^I\/O Timings:\s+(.*)$/);
|
|
579
|
+
if (!m?.[1]) return false;
|
|
580
|
+
const read = m[1].match(/(?:^|\s)read=(\d+(?:\.\d+)?)/);
|
|
581
|
+
const write = m[1].match(/(?:^|\s)write=(\d+(?:\.\d+)?)/);
|
|
582
|
+
if (read?.[1]) node["I/O Read Time"] = Number(read[1]);
|
|
583
|
+
if (write?.[1]) node["I/O Write Time"] = Number(write[1]);
|
|
584
|
+
return true;
|
|
585
|
+
}
|
|
586
|
+
function parseSettings(text) {
|
|
587
|
+
const out = {};
|
|
588
|
+
for (const pair of splitList(text)) {
|
|
589
|
+
const m = pair.match(/^(\S+)\s*=\s*(.*)$/);
|
|
590
|
+
if (m?.[1] && m[2] !== void 0) out[m[1]] = m[2].replace(/^'|'$/g, "");
|
|
591
|
+
}
|
|
592
|
+
return out;
|
|
593
|
+
}
|
|
594
|
+
var LIST_KEYS = /* @__PURE__ */ new Set(["Output", "Sort Key", "Presorted Key", "Group Key"]);
|
|
595
|
+
function parseTextToStatements(input) {
|
|
596
|
+
const statements = [];
|
|
597
|
+
let stmt = null;
|
|
598
|
+
let stack = [];
|
|
599
|
+
let current = null;
|
|
600
|
+
let jit = null;
|
|
601
|
+
const finish = () => {
|
|
602
|
+
if (stmt?.Plan) statements.push(stmt);
|
|
603
|
+
stmt = null;
|
|
604
|
+
stack = [];
|
|
605
|
+
current = null;
|
|
606
|
+
jit = null;
|
|
607
|
+
};
|
|
608
|
+
for (let raw of splitIntoLines(input)) {
|
|
609
|
+
raw = raw.replace(/"\s*$/, "").replace(/^\s*"/, "").replace(/\t/g, " ");
|
|
610
|
+
const depth = raw.match(/^\s*/)?.[0].length ?? 0;
|
|
611
|
+
const line = raw.slice(depth);
|
|
612
|
+
if (line === "" || headerRe.test(line)) {
|
|
613
|
+
if (line === "" && stmt?.Plan) finish();
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
const nodeM = nodeRe.exec(line);
|
|
617
|
+
const subM = subRe.exec(line);
|
|
618
|
+
const cteM = cteRe.exec(line);
|
|
619
|
+
if (nodeM && !subM && !cteM) {
|
|
620
|
+
if (!stmt) stmt = {};
|
|
621
|
+
jit = null;
|
|
622
|
+
const node = { ...splitNodeType(nodeM[3] ?? "") };
|
|
623
|
+
if (nodeM[2]) node["Partial Mode"] = nodeM[2];
|
|
624
|
+
const startup = nodeM[4] ?? nodeM[13];
|
|
625
|
+
const total = nodeM[5] ?? nodeM[14];
|
|
626
|
+
if (startup && total) {
|
|
627
|
+
node["Startup Cost"] = Number(startup);
|
|
628
|
+
node["Total Cost"] = Number(total);
|
|
629
|
+
node["Plan Rows"] = Number(nodeM[6] ?? nodeM[15]);
|
|
630
|
+
node["Plan Width"] = Number(nodeM[7] ?? nodeM[16]);
|
|
631
|
+
}
|
|
632
|
+
const st = nodeM[8] ?? nodeM[17];
|
|
633
|
+
const tt = nodeM[9] ?? nodeM[18];
|
|
634
|
+
if (st && tt) {
|
|
635
|
+
node["Actual Startup Time"] = Number(st);
|
|
636
|
+
node["Actual Total Time"] = Number(tt);
|
|
637
|
+
}
|
|
638
|
+
const rows = nodeM[10] ?? nodeM[19];
|
|
639
|
+
const loops = nodeM[11] ?? nodeM[20];
|
|
640
|
+
if (rows && loops) {
|
|
641
|
+
node["Actual Rows"] = Number(rows);
|
|
642
|
+
node["Actual Loops"] = Number(loops);
|
|
643
|
+
}
|
|
644
|
+
if (nodeM[12] ?? nodeM[21]) {
|
|
645
|
+
node["Actual Loops"] = 0;
|
|
646
|
+
node["Actual Rows"] = 0;
|
|
647
|
+
}
|
|
648
|
+
stack = stack.filter((f) => f.depth < depth);
|
|
649
|
+
const parent = stack[stack.length - 1];
|
|
650
|
+
if (!parent) {
|
|
651
|
+
stmt.Plan = node;
|
|
652
|
+
} else {
|
|
653
|
+
if (parent.rel) {
|
|
654
|
+
node["Parent Relationship"] = parent.rel;
|
|
655
|
+
if (parent.name) node["Subplan Name"] = parent.name;
|
|
656
|
+
}
|
|
657
|
+
const parentNode = parent.node;
|
|
658
|
+
if (!parentNode.Plans) parentNode.Plans = [];
|
|
659
|
+
parentNode.Plans.push(node);
|
|
660
|
+
}
|
|
661
|
+
stack.push({ depth, node });
|
|
662
|
+
current = node;
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
if (subM || cteM) {
|
|
666
|
+
stack = stack.filter((f) => f.depth < depth);
|
|
667
|
+
const parent = stack[stack.length - 1];
|
|
668
|
+
if (!parent) continue;
|
|
669
|
+
if (cteM?.[1])
|
|
670
|
+
stack.push({ depth, node: parent.node, rel: "InitPlan", name: `CTE ${cteM[1]}` });
|
|
671
|
+
else if (subM?.[1])
|
|
672
|
+
stack.push({
|
|
673
|
+
depth,
|
|
674
|
+
node: parent.node,
|
|
675
|
+
rel: subM[1],
|
|
676
|
+
name: (subM[0] ?? "").trim()
|
|
677
|
+
});
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
const workerM = workerRe.exec(line);
|
|
681
|
+
if (workerM && current) {
|
|
682
|
+
const worker = { "Worker Number": Number(workerM[1]) };
|
|
683
|
+
if (workerM[2] && workerM[3]) {
|
|
684
|
+
worker["Actual Startup Time"] = Number(workerM[2]);
|
|
685
|
+
worker["Actual Total Time"] = Number(workerM[3]);
|
|
686
|
+
}
|
|
687
|
+
if (workerM[4] && workerM[5]) {
|
|
688
|
+
worker["Actual Rows"] = Number(workerM[4]);
|
|
689
|
+
worker["Actual Loops"] = Number(workerM[5]);
|
|
690
|
+
}
|
|
691
|
+
if (!Array.isArray(current.Workers)) current.Workers = [];
|
|
692
|
+
current.Workers.push(worker);
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
const trigM = triggerRe.exec(line);
|
|
696
|
+
if (trigM && stmt) {
|
|
697
|
+
if (!Array.isArray(stmt.Triggers)) stmt.Triggers = [];
|
|
698
|
+
stmt.Triggers.push({
|
|
699
|
+
"Trigger Name": trigM[1],
|
|
700
|
+
Time: Number(trigM[2]),
|
|
701
|
+
Calls: Number(trigM[3])
|
|
702
|
+
});
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
const kv = line.match(/^([^:]+):\s*(.*)$/);
|
|
706
|
+
if (!kv?.[1]) continue;
|
|
707
|
+
const key = kv[1].trim();
|
|
708
|
+
const value = (kv[2] ?? "").trim();
|
|
709
|
+
if (key === "JIT") {
|
|
710
|
+
jit = {};
|
|
711
|
+
if (stmt) stmt.JIT = jit;
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
if (jit) {
|
|
715
|
+
if (key === "Functions") jit.Functions = Number(value);
|
|
716
|
+
else if (key === "Timing") {
|
|
717
|
+
const timing = {};
|
|
718
|
+
for (const part of value.split(/,\s*/)) {
|
|
719
|
+
const t = part.match(/^(\S+)\s+(\d+\.\d+)\s*ms/);
|
|
720
|
+
if (t?.[1]) timing[t[1]] = Number(t[2]);
|
|
721
|
+
}
|
|
722
|
+
jit.Timing = timing;
|
|
723
|
+
}
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
if (key === "Planning Time") {
|
|
727
|
+
if (stmt) stmt["Planning Time"] = parseFloat(value);
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
if (key === "Execution Time" || key === "Total runtime") {
|
|
731
|
+
if (stmt) stmt["Execution Time"] = parseFloat(value);
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
if (key === "Settings") {
|
|
735
|
+
if (stmt) stmt.Settings = parseSettings(value);
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
if (!current) continue;
|
|
739
|
+
if (parseSort(line, current) || parseBuffers(line, current) || parseWal(line, current) || parseIoTimings(line, current)) {
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
current[key] = LIST_KEYS.has(key) ? splitList(value) : numeric(value);
|
|
743
|
+
}
|
|
744
|
+
finish();
|
|
745
|
+
return statements;
|
|
746
|
+
}
|
|
445
747
|
var PlanNodeSchema = z.looseObject({
|
|
446
748
|
"Node Type": z.string(),
|
|
447
749
|
get Plans() {
|
|
@@ -561,8 +863,13 @@ function normalizeNode(raw, nextId) {
|
|
|
561
863
|
ioReadTime: num(raw, "I/O Read Time"),
|
|
562
864
|
ioWriteTime: num(raw, "I/O Write Time"),
|
|
563
865
|
workersPlanned: num(raw, "Workers Planned"),
|
|
564
|
-
workersLaunched: num(raw, "Workers Launched")
|
|
866
|
+
workersLaunched: num(raw, "Workers Launched"),
|
|
867
|
+
walRecords: num(raw, "WAL Records"),
|
|
868
|
+
walBytes: num(raw, "WAL Bytes"),
|
|
869
|
+
walFpi: num(raw, "WAL FPI")
|
|
565
870
|
});
|
|
871
|
+
const workers = parseWorkers(raw.Workers);
|
|
872
|
+
if (workers.length) node.workers = workers;
|
|
566
873
|
const childPlans = raw.Plans;
|
|
567
874
|
if (Array.isArray(childPlans)) {
|
|
568
875
|
for (const child of childPlans) {
|
|
@@ -576,6 +883,24 @@ function assign(target, fields) {
|
|
|
576
883
|
if (v !== void 0) target[k] = v;
|
|
577
884
|
}
|
|
578
885
|
}
|
|
886
|
+
function parseWorkers(raw) {
|
|
887
|
+
if (!Array.isArray(raw)) return [];
|
|
888
|
+
const out = [];
|
|
889
|
+
for (const w of raw) {
|
|
890
|
+
const r = w;
|
|
891
|
+
const number = num(r, "Worker Number");
|
|
892
|
+
if (number === void 0) continue;
|
|
893
|
+
const stat = { number };
|
|
894
|
+
assign(stat, {
|
|
895
|
+
actualRows: num(r, "Actual Rows"),
|
|
896
|
+
actualLoops: num(r, "Actual Loops"),
|
|
897
|
+
actualStartupTime: num(r, "Actual Startup Time"),
|
|
898
|
+
actualTotalTime: num(r, "Actual Total Time")
|
|
899
|
+
});
|
|
900
|
+
out.push(stat);
|
|
901
|
+
}
|
|
902
|
+
return out;
|
|
903
|
+
}
|
|
579
904
|
function parseTriggers(raw) {
|
|
580
905
|
if (!Array.isArray(raw)) return [];
|
|
581
906
|
return raw.map((t) => {
|
|
@@ -609,6 +934,36 @@ function parseJit(raw) {
|
|
|
609
934
|
}
|
|
610
935
|
return jit;
|
|
611
936
|
}
|
|
937
|
+
function statementToTree(stmt) {
|
|
938
|
+
let id = 0;
|
|
939
|
+
const root = normalizeNode(stmt.Plan, () => id++);
|
|
940
|
+
const hasAnalyze = root.actualLoops !== void 0 || stmt["Execution Time"] !== void 0;
|
|
941
|
+
const hasBuffers = root.sharedHitBlocks !== void 0 || root.sharedReadBlocks !== void 0;
|
|
942
|
+
const tree = {
|
|
943
|
+
root,
|
|
944
|
+
triggers: parseTriggers(stmt.Triggers),
|
|
945
|
+
hasAnalyze,
|
|
946
|
+
hasBuffers,
|
|
947
|
+
raw: stmt.Plan
|
|
948
|
+
};
|
|
949
|
+
if (typeof stmt["Planning Time"] === "number") tree.planningTime = stmt["Planning Time"];
|
|
950
|
+
if (typeof stmt["Execution Time"] === "number") tree.executionTime = stmt["Execution Time"];
|
|
951
|
+
const serialization = stmt.Serialization;
|
|
952
|
+
if (serialization && typeof serialization === "object") {
|
|
953
|
+
const t = num(serialization, "Time");
|
|
954
|
+
if (t !== void 0) tree.serializationTime = t;
|
|
955
|
+
}
|
|
956
|
+
const jit = parseJit(stmt.JIT);
|
|
957
|
+
if (jit) tree.jit = jit;
|
|
958
|
+
if (stmt.Settings) tree.settings = stmt.Settings;
|
|
959
|
+
return tree;
|
|
960
|
+
}
|
|
961
|
+
function parseExplain(input) {
|
|
962
|
+
return /^\s*[[{]/.test(input) ? parseExplainJson(input) : parseExplainText(input);
|
|
963
|
+
}
|
|
964
|
+
function parseExplainText(input) {
|
|
965
|
+
return parseTextToStatements(input).map(statementToTree);
|
|
966
|
+
}
|
|
612
967
|
function parseExplainJson(input) {
|
|
613
968
|
const json = parseJsonWithLocation(input);
|
|
614
969
|
let candidate = json;
|
|
@@ -623,25 +978,7 @@ function parseExplainJson(input) {
|
|
|
623
978
|
location: { kind: "input" }
|
|
624
979
|
});
|
|
625
980
|
}
|
|
626
|
-
return result.data.map((stmt) =>
|
|
627
|
-
let id = 0;
|
|
628
|
-
const root = normalizeNode(stmt.Plan, () => id++);
|
|
629
|
-
const hasAnalyze = root.actualLoops !== void 0 || stmt["Execution Time"] !== void 0;
|
|
630
|
-
const hasBuffers = root.sharedHitBlocks !== void 0 || root.sharedReadBlocks !== void 0;
|
|
631
|
-
const tree = {
|
|
632
|
-
root,
|
|
633
|
-
triggers: parseTriggers(stmt.Triggers),
|
|
634
|
-
hasAnalyze,
|
|
635
|
-
hasBuffers,
|
|
636
|
-
raw: stmt.Plan
|
|
637
|
-
};
|
|
638
|
-
if (stmt["Planning Time"] !== void 0) tree.planningTime = stmt["Planning Time"];
|
|
639
|
-
if (stmt["Execution Time"] !== void 0) tree.executionTime = stmt["Execution Time"];
|
|
640
|
-
const jit = parseJit(stmt.JIT);
|
|
641
|
-
if (jit) tree.jit = jit;
|
|
642
|
-
if (stmt.Settings) tree.settings = stmt.Settings;
|
|
643
|
-
return tree;
|
|
644
|
-
});
|
|
981
|
+
return result.data.map((stmt) => statementToTree(stmt));
|
|
645
982
|
}
|
|
646
983
|
function walk(node, visit) {
|
|
647
984
|
visit(node);
|
|
@@ -706,6 +1043,31 @@ function executionMs(tree) {
|
|
|
706
1043
|
function bottlenecks(tree, n = 5) {
|
|
707
1044
|
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);
|
|
708
1045
|
}
|
|
1046
|
+
function aggregateStats(tree) {
|
|
1047
|
+
const total = executionMs(tree) ?? 0;
|
|
1048
|
+
const groupBy = (keyOf) => {
|
|
1049
|
+
const acc = /* @__PURE__ */ new Map();
|
|
1050
|
+
for (const n of flatten(tree.root)) {
|
|
1051
|
+
const key = keyOf(n);
|
|
1052
|
+
if (!key) continue;
|
|
1053
|
+
const e = acc.get(key) ?? { count: 0, selfMs: 0 };
|
|
1054
|
+
e.count++;
|
|
1055
|
+
e.selfMs += n.metrics.selfMs ?? 0;
|
|
1056
|
+
acc.set(key, e);
|
|
1057
|
+
}
|
|
1058
|
+
return [...acc.entries()].map(([key, e]) => ({
|
|
1059
|
+
key,
|
|
1060
|
+
count: e.count,
|
|
1061
|
+
selfMs: e.selfMs,
|
|
1062
|
+
pctOfTotal: total > 0 ? 100 * e.selfMs / total : 0
|
|
1063
|
+
})).sort((a, b) => b.selfMs - a.selfMs || b.count - a.count);
|
|
1064
|
+
};
|
|
1065
|
+
return {
|
|
1066
|
+
byNodeType: groupBy((n) => n.nodeType),
|
|
1067
|
+
byRelation: groupBy((n) => n.relationName),
|
|
1068
|
+
byIndex: groupBy((n) => n.indexName)
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
709
1071
|
function nodeLabel(node) {
|
|
710
1072
|
let label = node.nodeType;
|
|
711
1073
|
if (node.indexName && node.relationName)
|
|
@@ -1290,8 +1652,8 @@ var rowMisestimate = {
|
|
|
1290
1652
|
const target = rel ?? "the underlying table";
|
|
1291
1653
|
const under = estimateDirection === "under";
|
|
1292
1654
|
const direction = under ? "underestimate" : "overestimate";
|
|
1293
|
-
const
|
|
1294
|
-
const detail = `Postgres estimated ${fmtInt(node.planRows)} rows but ${fmtInt(
|
|
1655
|
+
const actual2 = totalRows ?? 0;
|
|
1656
|
+
const detail = `Postgres estimated ${fmtInt(node.planRows)} rows but ${fmtInt(actual2)} were produced \u2014 a ${fmtInt(factor)}x ${direction}${onRel}.`;
|
|
1295
1657
|
return [
|
|
1296
1658
|
makeFinding(rowMisestimate, ctx, node, {
|
|
1297
1659
|
// Severity: underestimates are the dangerous ones (under-sized joins/memory).
|
|
@@ -1326,7 +1688,7 @@ ANALYZE ${rel ?? "<relation>"};`
|
|
|
1326
1688
|
docsUrl: `${DOCS2}/planner-stats.html`,
|
|
1327
1689
|
meta: {
|
|
1328
1690
|
estimatedRows: Math.round(node.planRows),
|
|
1329
|
-
actualRows: Math.round(
|
|
1691
|
+
actualRows: Math.round(actual2),
|
|
1330
1692
|
factor,
|
|
1331
1693
|
direction: estimateDirection
|
|
1332
1694
|
}
|
|
@@ -1671,6 +2033,138 @@ function redactPlanTree(tree) {
|
|
|
1671
2033
|
walk(tree.root, redactNode);
|
|
1672
2034
|
}
|
|
1673
2035
|
|
|
2036
|
+
// src/locks/advisor.ts
|
|
2037
|
+
var DOCS3 = "https://www.postgresql.org/docs/current";
|
|
2038
|
+
function analyzeLocks(sql, tree) {
|
|
2039
|
+
const code = stripSql(sql);
|
|
2040
|
+
const upper = code.toUpperCase();
|
|
2041
|
+
const kw = (code.trim().split(/\s+/)[0] ?? "").toUpperCase();
|
|
2042
|
+
const out = [];
|
|
2043
|
+
const add = (id, severity, parts) => {
|
|
2044
|
+
out.push({
|
|
2045
|
+
code: id,
|
|
2046
|
+
domain: "plan",
|
|
2047
|
+
severity,
|
|
2048
|
+
title: parts.title,
|
|
2049
|
+
detail: parts.detail,
|
|
2050
|
+
cause: parts.cause,
|
|
2051
|
+
remediation: { summary: parts.fix, commands: parts.commands },
|
|
2052
|
+
docsUrl: `${DOCS3}/explicit-locking.html`
|
|
2053
|
+
});
|
|
2054
|
+
};
|
|
2055
|
+
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)) {
|
|
2056
|
+
add("PGX_LOCK_TABLE_REWRITE", "error", {
|
|
2057
|
+
title: "Operation rewrites the table under an ACCESS EXCLUSIVE lock",
|
|
2058
|
+
detail: "VACUUM FULL / CLUSTER / a column-type change rewrites the whole table and holds ACCESS EXCLUSIVE for the duration.",
|
|
2059
|
+
cause: "ACCESS EXCLUSIVE blocks every reader and writer until the rewrite finishes \u2014 an outage on a busy table.",
|
|
2060
|
+
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.",
|
|
2061
|
+
commands: [{ label: "Bound the wait", sql: "SET lock_timeout = '3s';" }]
|
|
2062
|
+
});
|
|
2063
|
+
}
|
|
2064
|
+
if (/\bCREATE\s+(UNIQUE\s+)?INDEX\b/.test(upper) && !/\bCONCURRENTLY\b/.test(upper)) {
|
|
2065
|
+
add("PGX_DDL_NO_CONCURRENTLY", "warn", {
|
|
2066
|
+
title: "CREATE INDEX without CONCURRENTLY blocks writes",
|
|
2067
|
+
detail: "A plain CREATE INDEX takes a SHARE lock, blocking all writes to the table until the build completes.",
|
|
2068
|
+
cause: "On a large or busy table the build can take minutes, during which inserts/updates/deletes are blocked.",
|
|
2069
|
+
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).",
|
|
2070
|
+
commands: [{ label: "Build online", sql: "CREATE INDEX CONCURRENTLY ON <table> (<cols>);" }]
|
|
2071
|
+
});
|
|
2072
|
+
}
|
|
2073
|
+
if (/\bDROP\s+INDEX\b/.test(upper) && !/\bCONCURRENTLY\b/.test(upper)) {
|
|
2074
|
+
add("PGX_DROP_INDEX_NO_CONCURRENTLY", "warn", {
|
|
2075
|
+
title: "DROP INDEX without CONCURRENTLY takes ACCESS EXCLUSIVE",
|
|
2076
|
+
detail: "A plain DROP INDEX locks the table with ACCESS EXCLUSIVE.",
|
|
2077
|
+
cause: "Readers and writers block until the drop completes.",
|
|
2078
|
+
fix: "Use DROP INDEX CONCURRENTLY to avoid blocking.",
|
|
2079
|
+
commands: [{ label: "Drop online", sql: "DROP INDEX CONCURRENTLY <index>;" }]
|
|
2080
|
+
});
|
|
2081
|
+
}
|
|
2082
|
+
if (/\bTRUNCATE\b/.test(upper)) {
|
|
2083
|
+
add("PGX_LOCK_TRUNCATE", "info", {
|
|
2084
|
+
title: "TRUNCATE takes an ACCESS EXCLUSIVE lock",
|
|
2085
|
+
detail: "TRUNCATE briefly locks the table with ACCESS EXCLUSIVE.",
|
|
2086
|
+
cause: "It is fast (no row scan) but still blocks all access while it runs and is transactional.",
|
|
2087
|
+
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.",
|
|
2088
|
+
commands: [{ label: "Bound the wait", sql: "SET lock_timeout = '3s';" }]
|
|
2089
|
+
});
|
|
2090
|
+
}
|
|
2091
|
+
if (/\bLOCK\s+TABLE\b/.test(upper)) {
|
|
2092
|
+
add("PGX_LOCK_TABLE_EXPLICIT", "info", {
|
|
2093
|
+
title: "Explicit LOCK TABLE",
|
|
2094
|
+
detail: "An explicit LOCK TABLE acquires the named lock mode for the rest of the transaction.",
|
|
2095
|
+
cause: "Holding a strong lock longer than necessary blocks other sessions.",
|
|
2096
|
+
fix: "Use the lowest lock mode that suffices and keep the transaction short."
|
|
2097
|
+
});
|
|
2098
|
+
}
|
|
2099
|
+
if (/\bFOR\s+(UPDATE|SHARE|NO\s+KEY\s+UPDATE|KEY\s+SHARE)\b/.test(upper) && !/\bLIMIT\b/.test(upper)) {
|
|
2100
|
+
add("PGX_SELECT_FOR_UPDATE_UNBOUNDED", "warn", {
|
|
2101
|
+
title: "Row-locking SELECT without a LIMIT",
|
|
2102
|
+
detail: "SELECT \u2026 FOR UPDATE/SHARE locks every row it matches, held until the transaction ends.",
|
|
2103
|
+
cause: "Locking an unbounded set increases contention and deadlock risk with concurrent updaters.",
|
|
2104
|
+
fix: "Bound the set with a deterministic ORDER BY + LIMIT (and process in batches); a consistent lock order also avoids deadlocks.",
|
|
2105
|
+
commands: [{ label: "Bound + order", sql: "SELECT \u2026 ORDER BY id FOR UPDATE LIMIT 100;" }]
|
|
2106
|
+
});
|
|
2107
|
+
}
|
|
2108
|
+
if (kw === "UPDATE" || kw === "DELETE") {
|
|
2109
|
+
if (!/\bWHERE\b/.test(upper)) {
|
|
2110
|
+
add("PGX_WRITE_NO_WHERE", "warn", {
|
|
2111
|
+
title: `${kw} without a WHERE clause locks every row`,
|
|
2112
|
+
detail: `This ${kw} touches the whole table, taking a row lock on every row until commit.`,
|
|
2113
|
+
cause: "All rows are locked for the transaction's duration, blocking concurrent writers and bloating the table.",
|
|
2114
|
+
fix: "Add a WHERE clause; for large rewrites, update in batches (e.g. by primary-key ranges) and commit between batches."
|
|
2115
|
+
});
|
|
2116
|
+
} else if (tree && hasSeqScanOnTarget(tree, targetTable(code, kw))) {
|
|
2117
|
+
const rel = targetTable(code, kw);
|
|
2118
|
+
add("PGX_UPDATE_UNINDEXED_PREDICATE", "warn", {
|
|
2119
|
+
title: `${kw} scans ${rel ?? "the table"} sequentially to find rows`,
|
|
2120
|
+
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.`,
|
|
2121
|
+
cause: "An unindexed predicate means more rows scanned and locked, and the locks are held until commit.",
|
|
2122
|
+
fix: `Index the ${kw}'s WHERE columns so it finds rows via an index and locks only what it changes.`,
|
|
2123
|
+
commands: [
|
|
2124
|
+
{
|
|
2125
|
+
label: "Index the predicate",
|
|
2126
|
+
sql: `CREATE INDEX ON ${rel ?? "<table>"} (<where columns>);`
|
|
2127
|
+
}
|
|
2128
|
+
]
|
|
2129
|
+
});
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
if (/^(ALTER|CREATE|DROP)\b/.test(kw) && !/\bCONCURRENTLY\b/.test(upper) && !/\bSET\s+LOCK_TIMEOUT\b/.test(upper)) {
|
|
2133
|
+
add("PGX_DDL_NO_LOCK_TIMEOUT", "warn", {
|
|
2134
|
+
title: "DDL without a lock_timeout can stall the whole table",
|
|
2135
|
+
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.",
|
|
2136
|
+
cause: "A blocked ACCESS EXCLUSIVE request sits at the head of the lock queue and blocks new readers/writers too.",
|
|
2137
|
+
fix: "Set a short lock_timeout before the DDL and retry, so it fails fast instead of forming a queue.",
|
|
2138
|
+
commands: [
|
|
2139
|
+
{
|
|
2140
|
+
label: "Fail fast, then retry",
|
|
2141
|
+
sql: "SET lock_timeout = '3s';\n-- run the DDL; on timeout, retry later"
|
|
2142
|
+
}
|
|
2143
|
+
]
|
|
2144
|
+
});
|
|
2145
|
+
}
|
|
2146
|
+
return out;
|
|
2147
|
+
}
|
|
2148
|
+
function stripSql(sql) {
|
|
2149
|
+
return sql.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/--[^\n]*/g, " ").replace(/'(?:[^']|'')*'/g, "''").replace(/"(?:[^"]|"")*"/g, '"x"');
|
|
2150
|
+
}
|
|
2151
|
+
function targetTable(code, kw) {
|
|
2152
|
+
const re = kw === "DELETE" ? /\bDELETE\s+FROM\s+([A-Za-z_][\w.]*)/i : /\bUPDATE\s+(?:ONLY\s+)?([A-Za-z_][\w.]*)/i;
|
|
2153
|
+
const m = re.exec(code);
|
|
2154
|
+
return m?.[1];
|
|
2155
|
+
}
|
|
2156
|
+
function hasSeqScanOnTarget(tree, table) {
|
|
2157
|
+
let found = false;
|
|
2158
|
+
walk(tree.root, (n) => {
|
|
2159
|
+
if (n.nodeType === "Seq Scan" && (!table || n.relationName === bareName(table))) found = true;
|
|
2160
|
+
});
|
|
2161
|
+
return found;
|
|
2162
|
+
}
|
|
2163
|
+
function bareName(qualified) {
|
|
2164
|
+
const parts = qualified.split(".");
|
|
2165
|
+
return parts[parts.length - 1] ?? qualified;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
1674
2168
|
// src/report/tree.ts
|
|
1675
2169
|
function treeLines(tree, glyphs) {
|
|
1676
2170
|
const lines = [];
|
|
@@ -1711,21 +2205,28 @@ function nodeSummary(node) {
|
|
|
1711
2205
|
// src/report/json.ts
|
|
1712
2206
|
var JSON_SCHEMA_VERSION = 1;
|
|
1713
2207
|
function renderJson(result, pretty = true) {
|
|
2208
|
+
return JSON.stringify(buildReport(result), null, pretty ? 2 : 0);
|
|
2209
|
+
}
|
|
2210
|
+
function buildReport(result) {
|
|
1714
2211
|
const { tree, diagnostics, bottlenecks: bottlenecks2 } = result;
|
|
1715
2212
|
const counts = { error: 0, warn: 0, info: 0 };
|
|
1716
2213
|
for (const d of diagnostics) counts[d.severity]++;
|
|
1717
|
-
|
|
2214
|
+
return {
|
|
1718
2215
|
schemaVersion: JSON_SCHEMA_VERSION,
|
|
1719
2216
|
verdict: result.verdict,
|
|
1720
2217
|
worstSeverity: result.worstSeverity,
|
|
1721
2218
|
summary: {
|
|
1722
2219
|
planningTimeMs: tree.planningTime ?? null,
|
|
1723
2220
|
executionTimeMs: executionMs(tree) ?? null,
|
|
2221
|
+
serializationTimeMs: tree.serializationTime ?? null,
|
|
1724
2222
|
hasAnalyze: tree.hasAnalyze,
|
|
1725
2223
|
hasBuffers: tree.hasBuffers,
|
|
1726
2224
|
nodeCount: flatten(tree.root).length,
|
|
1727
2225
|
findings: counts
|
|
1728
2226
|
},
|
|
2227
|
+
triggers: tree.triggers,
|
|
2228
|
+
jit: tree.jit ?? null,
|
|
2229
|
+
settings: tree.settings ?? null,
|
|
1729
2230
|
diagnostics,
|
|
1730
2231
|
bottlenecks: bottlenecks2.filter((n) => (n.metrics.selfMs ?? 0) > 0).map((n) => ({
|
|
1731
2232
|
id: n.id,
|
|
@@ -1736,9 +2237,9 @@ function renderJson(result, pretty = true) {
|
|
|
1736
2237
|
pctOfTotal: n.metrics.pctOfTotal ?? null,
|
|
1737
2238
|
totalRows: n.metrics.totalRows ?? null
|
|
1738
2239
|
})),
|
|
2240
|
+
stats: aggregateStats(tree),
|
|
1739
2241
|
plan: serializeNode(tree.root)
|
|
1740
2242
|
};
|
|
1741
|
-
return JSON.stringify(report, null, pretty ? 2 : 0);
|
|
1742
2243
|
}
|
|
1743
2244
|
function serializeNode(node) {
|
|
1744
2245
|
const { children, metrics, raw, ...fields } = node;
|
|
@@ -2031,14 +2532,15 @@ function render(result, opts) {
|
|
|
2031
2532
|
|
|
2032
2533
|
// src/index.ts
|
|
2033
2534
|
function analyze(input, options = {}) {
|
|
2034
|
-
const trees =
|
|
2535
|
+
const trees = parseExplain(input);
|
|
2035
2536
|
const tree = selectStatement(trees, options.statement);
|
|
2036
2537
|
if (options.redact) redactPlanTree(tree);
|
|
2037
2538
|
computeMetrics(tree);
|
|
2038
2539
|
const result = runAdvisor(tree, options.config ?? DEFAULT_CONFIG);
|
|
2039
|
-
const
|
|
2040
|
-
if (
|
|
2041
|
-
|
|
2540
|
+
const extra = planNotices(tree);
|
|
2541
|
+
if (options.sql) extra.push(...analyzeLocks(options.sql, tree));
|
|
2542
|
+
if (extra.length) {
|
|
2543
|
+
result.diagnostics = [...result.diagnostics, ...extra].sort(bySeverity);
|
|
2042
2544
|
result.worstSeverity = result.diagnostics.reduce(
|
|
2043
2545
|
(worst, d) => worst === null ? d.severity : maxSeverity(worst, d.severity),
|
|
2044
2546
|
null
|
|
@@ -2070,6 +2572,6 @@ function planNotices(tree) {
|
|
|
2070
2572
|
return notices;
|
|
2071
2573
|
}
|
|
2072
2574
|
|
|
2073
|
-
export { AppError, DEFAULT_CONFIG, DEFAULT_THRESHOLDS, ExitCode, FORMATS, JSON_SCHEMA_VERSION, analyze, bottlenecks, computeMetrics, executionMs, flatten, isFormat, nodeLabel, parseExplainJson, render, runAdvisor, scrubCredentials, walk };
|
|
2575
|
+
export { AppError, DEFAULT_CONFIG, DEFAULT_THRESHOLDS, ExitCode, FORMATS, JSON_SCHEMA_VERSION, analyze, analyzeLocks, bottlenecks, computeMetrics, executionMs, flatten, isFormat, nodeLabel, parseExplain, parseExplainJson, render, runAdvisor, scrubCredentials, walk };
|
|
2074
2576
|
//# sourceMappingURL=index.js.map
|
|
2075
2577
|
//# sourceMappingURL=index.js.map
|