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/index.js
CHANGED
|
@@ -37,6 +37,9 @@ function bySeverity(a, b) {
|
|
|
37
37
|
function maxSeverity(a, b) {
|
|
38
38
|
return SEVERITY_RANK[a] <= SEVERITY_RANK[b] ? a : b;
|
|
39
39
|
}
|
|
40
|
+
function severityAtLeast(s, threshold) {
|
|
41
|
+
return SEVERITY_RANK[s] <= SEVERITY_RANK[threshold];
|
|
42
|
+
}
|
|
40
43
|
function scrubCredentials(input) {
|
|
41
44
|
if (!input) return input;
|
|
42
45
|
return input.replace(/(\b[a-z][a-z0-9+.-]*:\/\/[^:/?#@\s]+:)([^@\s]+)(@)/gi, "$1***$3").replace(/\bpassword\s*=\s*'[^']*'/gi, "password='***'").replace(/(\bpassword\s*=\s*)([^\s&'"]+)/gi, "$1***").replace(/(\bPGPASSWORD\s*=\s*)([^\s&'"]+)/gi, "$1***");
|
|
@@ -436,12 +439,316 @@ var DEFAULT_THRESHOLDS = {
|
|
|
436
439
|
correlatedLoops: 1e3,
|
|
437
440
|
jitPct: 25,
|
|
438
441
|
triggerPct: 10,
|
|
439
|
-
lowCacheHitRatio: 0.9
|
|
442
|
+
lowCacheHitRatio: 0.9,
|
|
443
|
+
limitDiscardRows: 1e4,
|
|
444
|
+
staleStatsModRatio: 0.2
|
|
440
445
|
};
|
|
441
446
|
var DEFAULT_CONFIG = {
|
|
442
447
|
thresholds: { ...DEFAULT_THRESHOLDS },
|
|
443
448
|
rules: {}
|
|
444
449
|
};
|
|
450
|
+
|
|
451
|
+
// src/core/parse-text.ts
|
|
452
|
+
var cap = (w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase();
|
|
453
|
+
var numeric = (v) => {
|
|
454
|
+
const t = v.trim();
|
|
455
|
+
return /^-?\d+(\.\d+)?$/.test(t) ? Number(t) : t;
|
|
456
|
+
};
|
|
457
|
+
function splitList(s) {
|
|
458
|
+
const out = [];
|
|
459
|
+
let depth = 0;
|
|
460
|
+
let cur = "";
|
|
461
|
+
for (const ch of s) {
|
|
462
|
+
if (ch === "(") depth++;
|
|
463
|
+
else if (ch === ")") depth--;
|
|
464
|
+
if (ch === "," && depth === 0) {
|
|
465
|
+
if (cur.trim()) out.push(cur.trim());
|
|
466
|
+
cur = "";
|
|
467
|
+
} else {
|
|
468
|
+
cur += ch;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (cur.trim()) out.push(cur.trim());
|
|
472
|
+
return out;
|
|
473
|
+
}
|
|
474
|
+
function splitIntoLines(text) {
|
|
475
|
+
const out = [];
|
|
476
|
+
const lines = text.split(/\r?\n/);
|
|
477
|
+
const count = (s, re) => (s.match(re) || []).length;
|
|
478
|
+
const closingFirst = (s) => {
|
|
479
|
+
const c = s.indexOf(")");
|
|
480
|
+
const o = s.indexOf("(");
|
|
481
|
+
return c !== -1 && c < o;
|
|
482
|
+
};
|
|
483
|
+
const sameIndent = (a, b) => a.search(/\S/) === b.search(/\S/);
|
|
484
|
+
for (const line of lines) {
|
|
485
|
+
const prev = out[out.length - 1];
|
|
486
|
+
if (prev && count(prev, /\)/g) !== count(prev, /\(/g)) {
|
|
487
|
+
out[out.length - 1] += line;
|
|
488
|
+
} else if (/^(?:Total\s+runtime|Planning(\s+time)?|Execution\s+time|Time|Filter|Output|JIT|Trigger|Settings|Serialization)/i.test(
|
|
489
|
+
line
|
|
490
|
+
)) {
|
|
491
|
+
out.push(line);
|
|
492
|
+
} else if (/^\S/.test(line) || /^\s*\(/.test(line) || closingFirst(line)) {
|
|
493
|
+
if (prev) out[out.length - 1] += line;
|
|
494
|
+
else out.push(line);
|
|
495
|
+
} else if (prev && /,\s*$/.test(prev) && !sameIndent(prev, line) && !/^\s*->/i.test(line)) {
|
|
496
|
+
out[out.length - 1] += line;
|
|
497
|
+
} else {
|
|
498
|
+
out.push(line);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return out;
|
|
502
|
+
}
|
|
503
|
+
var estimation = String.raw`\(cost=(\d+\.\d+)\.\.(\d+\.\d+)\s+rows=(\d+)\s+width=(\d+)\)`;
|
|
504
|
+
var actual = String.raw`(?:actual(?:\stime=(\d+\.\d+)\.\.(\d+\.\d+))?\srows=(\d+(?:\.\d+)?)\sloops=(\d+)|(never\s+executed))`;
|
|
505
|
+
var nodeRe = new RegExp(
|
|
506
|
+
String.raw`^(\s*->\s*|\s*)(Finalize|Simple|Partial)*\s*([^\r\n\t\f\v(]*?)\s*` + String.raw`(?:(?:${estimation}\s+\(${actual}\))|(?:${estimation})|(?:\(${actual}\)))\s*$`
|
|
507
|
+
);
|
|
508
|
+
var subRe = /^((?:Sub|Init)Plan)\s*(?:\d+\s*)?(?:\(returns.*\))?\s*$/;
|
|
509
|
+
var cteRe = /^CTE\s+(\S+)\s*$/;
|
|
510
|
+
var workerRe = /^Worker\s+(\d+):\s+(?:actual(?:\stime=(\d+\.\d+)\.\.(\d+\.\d+))?\srows=(\d+(?:\.\d+)?)\sloops=(\d+)|never\s+executed)(.*)$/;
|
|
511
|
+
var triggerRe = /^Trigger\s+(.*):\s+time=(\d+\.\d+)\s+calls=(\d+)\s*$/;
|
|
512
|
+
var headerRe = /^(QUERY PLAN|-{2,}|#|\(\d+ rows?\))/;
|
|
513
|
+
function splitNodeType(text) {
|
|
514
|
+
let s = text.trim();
|
|
515
|
+
let indexName;
|
|
516
|
+
let relationName;
|
|
517
|
+
let schema;
|
|
518
|
+
let alias;
|
|
519
|
+
const using = s.match(/\susing (\S+)/);
|
|
520
|
+
if (using?.[1]) {
|
|
521
|
+
indexName = using[1];
|
|
522
|
+
s = s.replace(using[0] ?? "", "");
|
|
523
|
+
}
|
|
524
|
+
const on = s.match(/\son (\S+?)(?:\s+(\S+))?\s*$/);
|
|
525
|
+
if (on?.[1]) {
|
|
526
|
+
let rel = on[1];
|
|
527
|
+
alias = on[2];
|
|
528
|
+
const dot = rel.lastIndexOf(".");
|
|
529
|
+
if (dot !== -1) {
|
|
530
|
+
schema = rel.slice(0, dot);
|
|
531
|
+
rel = rel.slice(dot + 1);
|
|
532
|
+
}
|
|
533
|
+
relationName = rel;
|
|
534
|
+
s = s.slice(0, on.index).trim();
|
|
535
|
+
}
|
|
536
|
+
const nodeType = s.replace(/^Parallel\s+/, "").trim();
|
|
537
|
+
if (nodeType === "Bitmap Index Scan" && relationName && !indexName) {
|
|
538
|
+
indexName = relationName;
|
|
539
|
+
relationName = void 0;
|
|
540
|
+
schema = void 0;
|
|
541
|
+
alias = void 0;
|
|
542
|
+
}
|
|
543
|
+
const out = { "Node Type": nodeType };
|
|
544
|
+
if (relationName) out["Relation Name"] = relationName;
|
|
545
|
+
if (indexName) out["Index Name"] = indexName;
|
|
546
|
+
if (schema) out.Schema = schema;
|
|
547
|
+
if (alias && alias !== relationName) out.Alias = alias;
|
|
548
|
+
return out;
|
|
549
|
+
}
|
|
550
|
+
function parseSort(text, node) {
|
|
551
|
+
const m = text.match(/^Sort Method:\s+(.*?)\s+(Memory|Disk):\s+(\S+)kB\s*$/);
|
|
552
|
+
if (!m?.[1] || !m[2] || m[3] === void 0) return false;
|
|
553
|
+
node["Sort Method"] = m[1].trim();
|
|
554
|
+
node["Sort Space Type"] = m[2];
|
|
555
|
+
node["Sort Space Used"] = Number(m[3]);
|
|
556
|
+
return true;
|
|
557
|
+
}
|
|
558
|
+
function parseBuffers(text, node) {
|
|
559
|
+
const m = text.match(/^Buffers:\s+(.*)$/);
|
|
560
|
+
if (!m?.[1]) return false;
|
|
561
|
+
for (const group of m[1].split(/,\s+/)) {
|
|
562
|
+
const g = group.match(/^(shared|temp|local)\s+(.*)$/);
|
|
563
|
+
if (!g?.[1] || g[2] === void 0) continue;
|
|
564
|
+
const type = cap(g[1]);
|
|
565
|
+
for (const kv of g[2].trim().split(/\s+/)) {
|
|
566
|
+
const [method, value] = kv.split("=");
|
|
567
|
+
if (method && value !== void 0) node[`${type} ${cap(method)} Blocks`] = Number(value);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return true;
|
|
571
|
+
}
|
|
572
|
+
function parseWal(text, node) {
|
|
573
|
+
const m = text.match(/^WAL:\s+(.*)$/);
|
|
574
|
+
if (!m?.[1]) return false;
|
|
575
|
+
for (const kv of m[1].trim().split(/\s+/)) {
|
|
576
|
+
const [k, value] = kv.split("=");
|
|
577
|
+
if (!k || value === void 0) continue;
|
|
578
|
+
node[`WAL ${k === "fpi" ? "FPI" : cap(k)}`] = Number(value);
|
|
579
|
+
}
|
|
580
|
+
return true;
|
|
581
|
+
}
|
|
582
|
+
function parseIoTimings(text, node) {
|
|
583
|
+
const m = text.match(/^I\/O Timings:\s+(.*)$/);
|
|
584
|
+
if (!m?.[1]) return false;
|
|
585
|
+
const read = m[1].match(/(?:^|\s)read=(\d+(?:\.\d+)?)/);
|
|
586
|
+
const write = m[1].match(/(?:^|\s)write=(\d+(?:\.\d+)?)/);
|
|
587
|
+
if (read?.[1]) node["I/O Read Time"] = Number(read[1]);
|
|
588
|
+
if (write?.[1]) node["I/O Write Time"] = Number(write[1]);
|
|
589
|
+
return true;
|
|
590
|
+
}
|
|
591
|
+
function parseSettings(text) {
|
|
592
|
+
const out = {};
|
|
593
|
+
for (const pair of splitList(text)) {
|
|
594
|
+
const m = pair.match(/^(\S+)\s*=\s*(.*)$/);
|
|
595
|
+
if (m?.[1] && m[2] !== void 0) out[m[1]] = m[2].replace(/^'|'$/g, "");
|
|
596
|
+
}
|
|
597
|
+
return out;
|
|
598
|
+
}
|
|
599
|
+
var LIST_KEYS = /* @__PURE__ */ new Set(["Output", "Sort Key", "Presorted Key", "Group Key"]);
|
|
600
|
+
function parseTextToStatements(input) {
|
|
601
|
+
const statements = [];
|
|
602
|
+
let stmt = null;
|
|
603
|
+
let stack = [];
|
|
604
|
+
let current = null;
|
|
605
|
+
let jit = null;
|
|
606
|
+
const finish = () => {
|
|
607
|
+
if (stmt?.Plan) statements.push(stmt);
|
|
608
|
+
stmt = null;
|
|
609
|
+
stack = [];
|
|
610
|
+
current = null;
|
|
611
|
+
jit = null;
|
|
612
|
+
};
|
|
613
|
+
for (let raw of splitIntoLines(input)) {
|
|
614
|
+
raw = raw.replace(/"\s*$/, "").replace(/^\s*"/, "").replace(/\t/g, " ");
|
|
615
|
+
const depth = raw.match(/^\s*/)?.[0].length ?? 0;
|
|
616
|
+
const line = raw.slice(depth);
|
|
617
|
+
if (line === "" || headerRe.test(line)) {
|
|
618
|
+
if (line === "" && stmt?.Plan) finish();
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
const nodeM = nodeRe.exec(line);
|
|
622
|
+
const subM = subRe.exec(line);
|
|
623
|
+
const cteM = cteRe.exec(line);
|
|
624
|
+
if (nodeM && !subM && !cteM) {
|
|
625
|
+
if (!stmt) stmt = {};
|
|
626
|
+
jit = null;
|
|
627
|
+
const node = { ...splitNodeType(nodeM[3] ?? "") };
|
|
628
|
+
if (nodeM[2]) node["Partial Mode"] = nodeM[2];
|
|
629
|
+
const startup = nodeM[4] ?? nodeM[13];
|
|
630
|
+
const total = nodeM[5] ?? nodeM[14];
|
|
631
|
+
if (startup && total) {
|
|
632
|
+
node["Startup Cost"] = Number(startup);
|
|
633
|
+
node["Total Cost"] = Number(total);
|
|
634
|
+
node["Plan Rows"] = Number(nodeM[6] ?? nodeM[15]);
|
|
635
|
+
node["Plan Width"] = Number(nodeM[7] ?? nodeM[16]);
|
|
636
|
+
}
|
|
637
|
+
const st = nodeM[8] ?? nodeM[17];
|
|
638
|
+
const tt = nodeM[9] ?? nodeM[18];
|
|
639
|
+
if (st && tt) {
|
|
640
|
+
node["Actual Startup Time"] = Number(st);
|
|
641
|
+
node["Actual Total Time"] = Number(tt);
|
|
642
|
+
}
|
|
643
|
+
const rows = nodeM[10] ?? nodeM[19];
|
|
644
|
+
const loops = nodeM[11] ?? nodeM[20];
|
|
645
|
+
if (rows && loops) {
|
|
646
|
+
node["Actual Rows"] = Number(rows);
|
|
647
|
+
node["Actual Loops"] = Number(loops);
|
|
648
|
+
}
|
|
649
|
+
if (nodeM[12] ?? nodeM[21]) {
|
|
650
|
+
node["Actual Loops"] = 0;
|
|
651
|
+
node["Actual Rows"] = 0;
|
|
652
|
+
}
|
|
653
|
+
stack = stack.filter((f) => f.depth < depth);
|
|
654
|
+
const parent = stack[stack.length - 1];
|
|
655
|
+
if (!parent) {
|
|
656
|
+
stmt.Plan = node;
|
|
657
|
+
} else {
|
|
658
|
+
if (parent.rel) {
|
|
659
|
+
node["Parent Relationship"] = parent.rel;
|
|
660
|
+
if (parent.name) node["Subplan Name"] = parent.name;
|
|
661
|
+
}
|
|
662
|
+
const parentNode = parent.node;
|
|
663
|
+
if (!parentNode.Plans) parentNode.Plans = [];
|
|
664
|
+
parentNode.Plans.push(node);
|
|
665
|
+
}
|
|
666
|
+
stack.push({ depth, node });
|
|
667
|
+
current = node;
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
if (subM || cteM) {
|
|
671
|
+
stack = stack.filter((f) => f.depth < depth);
|
|
672
|
+
const parent = stack[stack.length - 1];
|
|
673
|
+
if (!parent) continue;
|
|
674
|
+
if (cteM?.[1])
|
|
675
|
+
stack.push({ depth, node: parent.node, rel: "InitPlan", name: `CTE ${cteM[1]}` });
|
|
676
|
+
else if (subM?.[1])
|
|
677
|
+
stack.push({
|
|
678
|
+
depth,
|
|
679
|
+
node: parent.node,
|
|
680
|
+
rel: subM[1],
|
|
681
|
+
name: (subM[0] ?? "").trim()
|
|
682
|
+
});
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
const workerM = workerRe.exec(line);
|
|
686
|
+
if (workerM && current) {
|
|
687
|
+
const worker = { "Worker Number": Number(workerM[1]) };
|
|
688
|
+
if (workerM[2] && workerM[3]) {
|
|
689
|
+
worker["Actual Startup Time"] = Number(workerM[2]);
|
|
690
|
+
worker["Actual Total Time"] = Number(workerM[3]);
|
|
691
|
+
}
|
|
692
|
+
if (workerM[4] && workerM[5]) {
|
|
693
|
+
worker["Actual Rows"] = Number(workerM[4]);
|
|
694
|
+
worker["Actual Loops"] = Number(workerM[5]);
|
|
695
|
+
}
|
|
696
|
+
if (!Array.isArray(current.Workers)) current.Workers = [];
|
|
697
|
+
current.Workers.push(worker);
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
const trigM = triggerRe.exec(line);
|
|
701
|
+
if (trigM && stmt) {
|
|
702
|
+
if (!Array.isArray(stmt.Triggers)) stmt.Triggers = [];
|
|
703
|
+
stmt.Triggers.push({
|
|
704
|
+
"Trigger Name": trigM[1],
|
|
705
|
+
Time: Number(trigM[2]),
|
|
706
|
+
Calls: Number(trigM[3])
|
|
707
|
+
});
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
const kv = line.match(/^([^:]+):\s*(.*)$/);
|
|
711
|
+
if (!kv?.[1]) continue;
|
|
712
|
+
const key = kv[1].trim();
|
|
713
|
+
const value = (kv[2] ?? "").trim();
|
|
714
|
+
if (key === "JIT") {
|
|
715
|
+
jit = {};
|
|
716
|
+
if (stmt) stmt.JIT = jit;
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
if (jit) {
|
|
720
|
+
if (key === "Functions") jit.Functions = Number(value);
|
|
721
|
+
else if (key === "Timing") {
|
|
722
|
+
const timing = {};
|
|
723
|
+
for (const part of value.split(/,\s*/)) {
|
|
724
|
+
const t = part.match(/^(\S+)\s+(\d+\.\d+)\s*ms/);
|
|
725
|
+
if (t?.[1]) timing[t[1]] = Number(t[2]);
|
|
726
|
+
}
|
|
727
|
+
jit.Timing = timing;
|
|
728
|
+
}
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
if (key === "Planning Time") {
|
|
732
|
+
if (stmt) stmt["Planning Time"] = parseFloat(value);
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
if (key === "Execution Time" || key === "Total runtime") {
|
|
736
|
+
if (stmt) stmt["Execution Time"] = parseFloat(value);
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
if (key === "Settings") {
|
|
740
|
+
if (stmt) stmt.Settings = parseSettings(value);
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
if (!current) continue;
|
|
744
|
+
if (parseSort(line, current) || parseBuffers(line, current) || parseWal(line, current) || parseIoTimings(line, current)) {
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
current[key] = LIST_KEYS.has(key) ? splitList(value) : numeric(value);
|
|
748
|
+
}
|
|
749
|
+
finish();
|
|
750
|
+
return statements;
|
|
751
|
+
}
|
|
445
752
|
var PlanNodeSchema = z.looseObject({
|
|
446
753
|
"Node Type": z.string(),
|
|
447
754
|
get Plans() {
|
|
@@ -550,6 +857,10 @@ function normalizeNode(raw, nextId) {
|
|
|
550
857
|
diskUsage: num(raw, "Disk Usage"),
|
|
551
858
|
exactHeapBlocks: num(raw, "Exact Heap Blocks"),
|
|
552
859
|
lossyHeapBlocks: num(raw, "Lossy Heap Blocks"),
|
|
860
|
+
cacheHits: num(raw, "Cache Hits"),
|
|
861
|
+
cacheMisses: num(raw, "Cache Misses"),
|
|
862
|
+
cacheEvictions: num(raw, "Cache Evictions"),
|
|
863
|
+
cacheOverflows: num(raw, "Cache Overflows"),
|
|
553
864
|
sharedHitBlocks: num(raw, "Shared Hit Blocks"),
|
|
554
865
|
sharedReadBlocks: num(raw, "Shared Read Blocks"),
|
|
555
866
|
sharedDirtiedBlocks: num(raw, "Shared Dirtied Blocks"),
|
|
@@ -561,8 +872,13 @@ function normalizeNode(raw, nextId) {
|
|
|
561
872
|
ioReadTime: num(raw, "I/O Read Time"),
|
|
562
873
|
ioWriteTime: num(raw, "I/O Write Time"),
|
|
563
874
|
workersPlanned: num(raw, "Workers Planned"),
|
|
564
|
-
workersLaunched: num(raw, "Workers Launched")
|
|
875
|
+
workersLaunched: num(raw, "Workers Launched"),
|
|
876
|
+
walRecords: num(raw, "WAL Records"),
|
|
877
|
+
walBytes: num(raw, "WAL Bytes"),
|
|
878
|
+
walFpi: num(raw, "WAL FPI")
|
|
565
879
|
});
|
|
880
|
+
const workers = parseWorkers(raw.Workers);
|
|
881
|
+
if (workers.length) node.workers = workers;
|
|
566
882
|
const childPlans = raw.Plans;
|
|
567
883
|
if (Array.isArray(childPlans)) {
|
|
568
884
|
for (const child of childPlans) {
|
|
@@ -576,6 +892,24 @@ function assign(target, fields) {
|
|
|
576
892
|
if (v !== void 0) target[k] = v;
|
|
577
893
|
}
|
|
578
894
|
}
|
|
895
|
+
function parseWorkers(raw) {
|
|
896
|
+
if (!Array.isArray(raw)) return [];
|
|
897
|
+
const out = [];
|
|
898
|
+
for (const w of raw) {
|
|
899
|
+
const r = w;
|
|
900
|
+
const number = num(r, "Worker Number");
|
|
901
|
+
if (number === void 0) continue;
|
|
902
|
+
const stat = { number };
|
|
903
|
+
assign(stat, {
|
|
904
|
+
actualRows: num(r, "Actual Rows"),
|
|
905
|
+
actualLoops: num(r, "Actual Loops"),
|
|
906
|
+
actualStartupTime: num(r, "Actual Startup Time"),
|
|
907
|
+
actualTotalTime: num(r, "Actual Total Time")
|
|
908
|
+
});
|
|
909
|
+
out.push(stat);
|
|
910
|
+
}
|
|
911
|
+
return out;
|
|
912
|
+
}
|
|
579
913
|
function parseTriggers(raw) {
|
|
580
914
|
if (!Array.isArray(raw)) return [];
|
|
581
915
|
return raw.map((t) => {
|
|
@@ -609,6 +943,36 @@ function parseJit(raw) {
|
|
|
609
943
|
}
|
|
610
944
|
return jit;
|
|
611
945
|
}
|
|
946
|
+
function statementToTree(stmt) {
|
|
947
|
+
let id = 0;
|
|
948
|
+
const root = normalizeNode(stmt.Plan, () => id++);
|
|
949
|
+
const hasAnalyze = root.actualLoops !== void 0 || stmt["Execution Time"] !== void 0;
|
|
950
|
+
const hasBuffers = root.sharedHitBlocks !== void 0 || root.sharedReadBlocks !== void 0;
|
|
951
|
+
const tree = {
|
|
952
|
+
root,
|
|
953
|
+
triggers: parseTriggers(stmt.Triggers),
|
|
954
|
+
hasAnalyze,
|
|
955
|
+
hasBuffers,
|
|
956
|
+
raw: stmt.Plan
|
|
957
|
+
};
|
|
958
|
+
if (typeof stmt["Planning Time"] === "number") tree.planningTime = stmt["Planning Time"];
|
|
959
|
+
if (typeof stmt["Execution Time"] === "number") tree.executionTime = stmt["Execution Time"];
|
|
960
|
+
const serialization = stmt.Serialization;
|
|
961
|
+
if (serialization && typeof serialization === "object") {
|
|
962
|
+
const t = num(serialization, "Time");
|
|
963
|
+
if (t !== void 0) tree.serializationTime = t;
|
|
964
|
+
}
|
|
965
|
+
const jit = parseJit(stmt.JIT);
|
|
966
|
+
if (jit) tree.jit = jit;
|
|
967
|
+
if (stmt.Settings) tree.settings = stmt.Settings;
|
|
968
|
+
return tree;
|
|
969
|
+
}
|
|
970
|
+
function parseExplain(input) {
|
|
971
|
+
return /^\s*[[{]/.test(input) ? parseExplainJson(input) : parseExplainText(input);
|
|
972
|
+
}
|
|
973
|
+
function parseExplainText(input) {
|
|
974
|
+
return parseTextToStatements(input).map(statementToTree);
|
|
975
|
+
}
|
|
612
976
|
function parseExplainJson(input) {
|
|
613
977
|
const json = parseJsonWithLocation(input);
|
|
614
978
|
let candidate = json;
|
|
@@ -623,25 +987,7 @@ function parseExplainJson(input) {
|
|
|
623
987
|
location: { kind: "input" }
|
|
624
988
|
});
|
|
625
989
|
}
|
|
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
|
-
});
|
|
990
|
+
return result.data.map((stmt) => statementToTree(stmt));
|
|
645
991
|
}
|
|
646
992
|
function walk(node, visit) {
|
|
647
993
|
visit(node);
|
|
@@ -706,6 +1052,31 @@ function executionMs(tree) {
|
|
|
706
1052
|
function bottlenecks(tree, n = 5) {
|
|
707
1053
|
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
1054
|
}
|
|
1055
|
+
function aggregateStats(tree) {
|
|
1056
|
+
const total = executionMs(tree) ?? 0;
|
|
1057
|
+
const groupBy = (keyOf) => {
|
|
1058
|
+
const acc = /* @__PURE__ */ new Map();
|
|
1059
|
+
for (const n of flatten(tree.root)) {
|
|
1060
|
+
const key = keyOf(n);
|
|
1061
|
+
if (!key) continue;
|
|
1062
|
+
const e = acc.get(key) ?? { count: 0, selfMs: 0 };
|
|
1063
|
+
e.count++;
|
|
1064
|
+
e.selfMs += n.metrics.selfMs ?? 0;
|
|
1065
|
+
acc.set(key, e);
|
|
1066
|
+
}
|
|
1067
|
+
return [...acc.entries()].map(([key, e]) => ({
|
|
1068
|
+
key,
|
|
1069
|
+
count: e.count,
|
|
1070
|
+
selfMs: e.selfMs,
|
|
1071
|
+
pctOfTotal: total > 0 ? 100 * e.selfMs / total : 0
|
|
1072
|
+
})).sort((a, b) => b.selfMs - a.selfMs || b.count - a.count);
|
|
1073
|
+
};
|
|
1074
|
+
return {
|
|
1075
|
+
byNodeType: groupBy((n) => n.nodeType),
|
|
1076
|
+
byRelation: groupBy((n) => n.relationName),
|
|
1077
|
+
byIndex: groupBy((n) => n.indexName)
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
709
1080
|
function nodeLabel(node) {
|
|
710
1081
|
let label = node.nodeType;
|
|
711
1082
|
if (node.indexName && node.relationName)
|
|
@@ -837,8 +1208,11 @@ var cartesianProduct = {
|
|
|
837
1208
|
check(node, ctx) {
|
|
838
1209
|
if (node.nodeType !== "Nested Loop") return [];
|
|
839
1210
|
if (node.joinFilter) return [];
|
|
840
|
-
|
|
1211
|
+
let inner = node.children[1];
|
|
841
1212
|
if (!inner) return [];
|
|
1213
|
+
while ((inner.nodeType === "Memoize" || inner.nodeType === "Materialize") && inner.children[0]) {
|
|
1214
|
+
inner = inner.children[0];
|
|
1215
|
+
}
|
|
842
1216
|
if (inner.indexCond || inner.recheckCond) return [];
|
|
843
1217
|
const outer = node.children[0];
|
|
844
1218
|
if (!outer) return [];
|
|
@@ -1157,6 +1531,47 @@ var indexOnlyHeapFetches = {
|
|
|
1157
1531
|
}
|
|
1158
1532
|
};
|
|
1159
1533
|
|
|
1534
|
+
// src/advisor/rules/limit-large-offset.ts
|
|
1535
|
+
var limitLargeOffset = {
|
|
1536
|
+
id: "PGX_LIMIT_LARGE_OFFSET",
|
|
1537
|
+
title: "LIMIT discards a large prefix (OFFSET pagination)",
|
|
1538
|
+
defaultSeverity: "warn",
|
|
1539
|
+
requiresAnalyze: true,
|
|
1540
|
+
check(node, ctx) {
|
|
1541
|
+
if (node.nodeType !== "Limit") return [];
|
|
1542
|
+
const child = outerChild(node);
|
|
1543
|
+
const emitted = node.metrics.totalRows;
|
|
1544
|
+
const produced = child?.metrics.totalRows;
|
|
1545
|
+
if (emitted === void 0 || produced === void 0) return [];
|
|
1546
|
+
const discarded = produced - emitted;
|
|
1547
|
+
if (discarded < ctx.thresholds.limitDiscardRows) return [];
|
|
1548
|
+
const rel = child?.relationName ?? "the input";
|
|
1549
|
+
return [
|
|
1550
|
+
makeFinding(limitLargeOffset, ctx, node, {
|
|
1551
|
+
title: `LIMIT discarded ${fmtInt(discarded)} rows before returning ${fmtInt(emitted)}`,
|
|
1552
|
+
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.`,
|
|
1553
|
+
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)).",
|
|
1554
|
+
remediation: {
|
|
1555
|
+
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.",
|
|
1556
|
+
steps: [
|
|
1557
|
+
"Order by a unique (or tie-broken) key, e.g. ORDER BY created_at, id.",
|
|
1558
|
+
"Pass the last row's key from the previous page instead of an OFFSET.",
|
|
1559
|
+
"Index the sort key so the WHERE clause seeks directly to the page start."
|
|
1560
|
+
],
|
|
1561
|
+
commands: [
|
|
1562
|
+
{
|
|
1563
|
+
label: "Keyset pagination instead of OFFSET",
|
|
1564
|
+
sql: "SELECT \u2026 FROM t WHERE (created_at, id) > ($last_created_at, $last_id) ORDER BY created_at, id LIMIT 50;"
|
|
1565
|
+
}
|
|
1566
|
+
]
|
|
1567
|
+
},
|
|
1568
|
+
docsUrl: `${DOCS2}/queries-limit.html`,
|
|
1569
|
+
meta: { discarded: Math.round(discarded), emitted: Math.round(emitted) }
|
|
1570
|
+
})
|
|
1571
|
+
];
|
|
1572
|
+
}
|
|
1573
|
+
};
|
|
1574
|
+
|
|
1160
1575
|
// src/advisor/rules/low-cache-hit.ts
|
|
1161
1576
|
var MIN_READ_BLOCKS = 1e3;
|
|
1162
1577
|
var lowCacheHit = {
|
|
@@ -1203,6 +1618,46 @@ var lowCacheHit = {
|
|
|
1203
1618
|
}
|
|
1204
1619
|
};
|
|
1205
1620
|
|
|
1621
|
+
// src/advisor/rules/memoize-evictions.ts
|
|
1622
|
+
var memoizeEvictions = {
|
|
1623
|
+
id: "PGX_MEMOIZE_EVICTIONS",
|
|
1624
|
+
title: "Memoize cache is thrashing",
|
|
1625
|
+
defaultSeverity: "warn",
|
|
1626
|
+
requiresAnalyze: true,
|
|
1627
|
+
check(node, ctx) {
|
|
1628
|
+
if (node.nodeType !== "Memoize") return [];
|
|
1629
|
+
const hits = node.cacheHits ?? 0;
|
|
1630
|
+
const evictions = node.cacheEvictions ?? 0;
|
|
1631
|
+
const overflows = node.cacheOverflows ?? 0;
|
|
1632
|
+
const thrashing = evictions > hits;
|
|
1633
|
+
if (!thrashing && overflows === 0) return [];
|
|
1634
|
+
return [
|
|
1635
|
+
makeFinding(memoizeEvictions, ctx, node, {
|
|
1636
|
+
title: overflows > 0 ? `Memoize cache overflowed ${fmtInt(overflows)} time(s)` : `Memoize evicted ${fmtInt(evictions)} entries against ${fmtInt(hits)} hits`,
|
|
1637
|
+
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.`,
|
|
1638
|
+
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.",
|
|
1639
|
+
remediation: {
|
|
1640
|
+
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.",
|
|
1641
|
+
steps: [
|
|
1642
|
+
"Estimate the distinct keys: the planner sizes the cache from ndistinct of the join key.",
|
|
1643
|
+
"Raise work_mem (or hash_mem_multiplier on PG 15+) for this workload and re-run.",
|
|
1644
|
+
"If the key space is genuinely huge, an index on the inner side may beat Memoize \u2014 compare with enable_memoize = off."
|
|
1645
|
+
],
|
|
1646
|
+
commands: [
|
|
1647
|
+
{ label: "More cache memory for this session", sql: "SET work_mem = '64MB';" },
|
|
1648
|
+
{
|
|
1649
|
+
label: "Compare the plan without Memoize",
|
|
1650
|
+
sql: "SET enable_memoize = off; EXPLAIN ANALYZE <query>;"
|
|
1651
|
+
}
|
|
1652
|
+
]
|
|
1653
|
+
},
|
|
1654
|
+
docsUrl: `${DOCS2}/runtime-config-resource.html`,
|
|
1655
|
+
meta: { hits, evictions, overflows }
|
|
1656
|
+
})
|
|
1657
|
+
];
|
|
1658
|
+
}
|
|
1659
|
+
};
|
|
1660
|
+
|
|
1206
1661
|
// src/advisor/rules/nested-loop-large-outer.ts
|
|
1207
1662
|
var nestedLoopLargeOuter = {
|
|
1208
1663
|
id: "PGX_NESTED_LOOP_LARGE_OUTER",
|
|
@@ -1290,8 +1745,8 @@ var rowMisestimate = {
|
|
|
1290
1745
|
const target = rel ?? "the underlying table";
|
|
1291
1746
|
const under = estimateDirection === "under";
|
|
1292
1747
|
const direction = under ? "underestimate" : "overestimate";
|
|
1293
|
-
const
|
|
1294
|
-
const detail = `Postgres estimated ${fmtInt(node.planRows)} rows but ${fmtInt(
|
|
1748
|
+
const actual2 = totalRows ?? 0;
|
|
1749
|
+
const detail = `Postgres estimated ${fmtInt(node.planRows)} rows but ${fmtInt(actual2)} were produced \u2014 a ${fmtInt(factor)}x ${direction}${onRel}.`;
|
|
1295
1750
|
return [
|
|
1296
1751
|
makeFinding(rowMisestimate, ctx, node, {
|
|
1297
1752
|
// Severity: underestimates are the dangerous ones (under-sized joins/memory).
|
|
@@ -1326,7 +1781,7 @@ ANALYZE ${rel ?? "<relation>"};`
|
|
|
1326
1781
|
docsUrl: `${DOCS2}/planner-stats.html`,
|
|
1327
1782
|
meta: {
|
|
1328
1783
|
estimatedRows: Math.round(node.planRows),
|
|
1329
|
-
actualRows: Math.round(
|
|
1784
|
+
actualRows: Math.round(actual2),
|
|
1330
1785
|
factor,
|
|
1331
1786
|
direction: estimateDirection
|
|
1332
1787
|
}
|
|
@@ -1585,8 +2040,10 @@ var ALL_RULES = [
|
|
|
1585
2040
|
seqScanLarge,
|
|
1586
2041
|
nestedLoopLargeOuter,
|
|
1587
2042
|
highFilterDiscard,
|
|
2043
|
+
limitLargeOffset,
|
|
1588
2044
|
sortSpillDisk,
|
|
1589
2045
|
hashSpillDisk,
|
|
2046
|
+
memoizeEvictions,
|
|
1590
2047
|
correlatedSubplan,
|
|
1591
2048
|
rowMisestimate,
|
|
1592
2049
|
filterCouldBeIndexCond,
|
|
@@ -1671,6 +2128,138 @@ function redactPlanTree(tree) {
|
|
|
1671
2128
|
walk(tree.root, redactNode);
|
|
1672
2129
|
}
|
|
1673
2130
|
|
|
2131
|
+
// src/locks/advisor.ts
|
|
2132
|
+
var DOCS3 = "https://www.postgresql.org/docs/current";
|
|
2133
|
+
function analyzeLocks(sql, tree) {
|
|
2134
|
+
const code = stripSql(sql);
|
|
2135
|
+
const upper = code.toUpperCase();
|
|
2136
|
+
const kw = (code.trim().split(/\s+/)[0] ?? "").toUpperCase();
|
|
2137
|
+
const out = [];
|
|
2138
|
+
const add = (id, severity, parts) => {
|
|
2139
|
+
out.push({
|
|
2140
|
+
code: id,
|
|
2141
|
+
domain: "plan",
|
|
2142
|
+
severity,
|
|
2143
|
+
title: parts.title,
|
|
2144
|
+
detail: parts.detail,
|
|
2145
|
+
cause: parts.cause,
|
|
2146
|
+
remediation: { summary: parts.fix, commands: parts.commands },
|
|
2147
|
+
docsUrl: `${DOCS3}/explicit-locking.html`
|
|
2148
|
+
});
|
|
2149
|
+
};
|
|
2150
|
+
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)) {
|
|
2151
|
+
add("PGX_LOCK_TABLE_REWRITE", "error", {
|
|
2152
|
+
title: "Operation rewrites the table under an ACCESS EXCLUSIVE lock",
|
|
2153
|
+
detail: "VACUUM FULL / CLUSTER / a column-type change rewrites the whole table and holds ACCESS EXCLUSIVE for the duration.",
|
|
2154
|
+
cause: "ACCESS EXCLUSIVE blocks every reader and writer until the rewrite finishes \u2014 an outage on a busy table.",
|
|
2155
|
+
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.",
|
|
2156
|
+
commands: [{ label: "Bound the wait", sql: "SET lock_timeout = '3s';" }]
|
|
2157
|
+
});
|
|
2158
|
+
}
|
|
2159
|
+
if (/\bCREATE\s+(UNIQUE\s+)?INDEX\b/.test(upper) && !/\bCONCURRENTLY\b/.test(upper)) {
|
|
2160
|
+
add("PGX_DDL_NO_CONCURRENTLY", "warn", {
|
|
2161
|
+
title: "CREATE INDEX without CONCURRENTLY blocks writes",
|
|
2162
|
+
detail: "A plain CREATE INDEX takes a SHARE lock, blocking all writes to the table until the build completes.",
|
|
2163
|
+
cause: "On a large or busy table the build can take minutes, during which inserts/updates/deletes are blocked.",
|
|
2164
|
+
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).",
|
|
2165
|
+
commands: [{ label: "Build online", sql: "CREATE INDEX CONCURRENTLY ON <table> (<cols>);" }]
|
|
2166
|
+
});
|
|
2167
|
+
}
|
|
2168
|
+
if (/\bDROP\s+INDEX\b/.test(upper) && !/\bCONCURRENTLY\b/.test(upper)) {
|
|
2169
|
+
add("PGX_DROP_INDEX_NO_CONCURRENTLY", "warn", {
|
|
2170
|
+
title: "DROP INDEX without CONCURRENTLY takes ACCESS EXCLUSIVE",
|
|
2171
|
+
detail: "A plain DROP INDEX locks the table with ACCESS EXCLUSIVE.",
|
|
2172
|
+
cause: "Readers and writers block until the drop completes.",
|
|
2173
|
+
fix: "Use DROP INDEX CONCURRENTLY to avoid blocking.",
|
|
2174
|
+
commands: [{ label: "Drop online", sql: "DROP INDEX CONCURRENTLY <index>;" }]
|
|
2175
|
+
});
|
|
2176
|
+
}
|
|
2177
|
+
if (/\bTRUNCATE\b/.test(upper)) {
|
|
2178
|
+
add("PGX_LOCK_TRUNCATE", "info", {
|
|
2179
|
+
title: "TRUNCATE takes an ACCESS EXCLUSIVE lock",
|
|
2180
|
+
detail: "TRUNCATE briefly locks the table with ACCESS EXCLUSIVE.",
|
|
2181
|
+
cause: "It is fast (no row scan) but still blocks all access while it runs and is transactional.",
|
|
2182
|
+
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.",
|
|
2183
|
+
commands: [{ label: "Bound the wait", sql: "SET lock_timeout = '3s';" }]
|
|
2184
|
+
});
|
|
2185
|
+
}
|
|
2186
|
+
if (/\bLOCK\s+TABLE\b/.test(upper)) {
|
|
2187
|
+
add("PGX_LOCK_TABLE_EXPLICIT", "info", {
|
|
2188
|
+
title: "Explicit LOCK TABLE",
|
|
2189
|
+
detail: "An explicit LOCK TABLE acquires the named lock mode for the rest of the transaction.",
|
|
2190
|
+
cause: "Holding a strong lock longer than necessary blocks other sessions.",
|
|
2191
|
+
fix: "Use the lowest lock mode that suffices and keep the transaction short."
|
|
2192
|
+
});
|
|
2193
|
+
}
|
|
2194
|
+
if (/\bFOR\s+(UPDATE|SHARE|NO\s+KEY\s+UPDATE|KEY\s+SHARE)\b/.test(upper) && !/\bLIMIT\b/.test(upper)) {
|
|
2195
|
+
add("PGX_SELECT_FOR_UPDATE_UNBOUNDED", "warn", {
|
|
2196
|
+
title: "Row-locking SELECT without a LIMIT",
|
|
2197
|
+
detail: "SELECT \u2026 FOR UPDATE/SHARE locks every row it matches, held until the transaction ends.",
|
|
2198
|
+
cause: "Locking an unbounded set increases contention and deadlock risk with concurrent updaters.",
|
|
2199
|
+
fix: "Bound the set with a deterministic ORDER BY + LIMIT (and process in batches); a consistent lock order also avoids deadlocks.",
|
|
2200
|
+
commands: [{ label: "Bound + order", sql: "SELECT \u2026 ORDER BY id FOR UPDATE LIMIT 100;" }]
|
|
2201
|
+
});
|
|
2202
|
+
}
|
|
2203
|
+
if (kw === "UPDATE" || kw === "DELETE") {
|
|
2204
|
+
if (!/\bWHERE\b/.test(upper)) {
|
|
2205
|
+
add("PGX_WRITE_NO_WHERE", "warn", {
|
|
2206
|
+
title: `${kw} without a WHERE clause locks every row`,
|
|
2207
|
+
detail: `This ${kw} touches the whole table, taking a row lock on every row until commit.`,
|
|
2208
|
+
cause: "All rows are locked for the transaction's duration, blocking concurrent writers and bloating the table.",
|
|
2209
|
+
fix: "Add a WHERE clause; for large rewrites, update in batches (e.g. by primary-key ranges) and commit between batches."
|
|
2210
|
+
});
|
|
2211
|
+
} else if (tree && hasSeqScanOnTarget(tree, targetTable(code, kw))) {
|
|
2212
|
+
const rel = targetTable(code, kw);
|
|
2213
|
+
add("PGX_UPDATE_UNINDEXED_PREDICATE", "warn", {
|
|
2214
|
+
title: `${kw} scans ${rel ?? "the table"} sequentially to find rows`,
|
|
2215
|
+
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.`,
|
|
2216
|
+
cause: "An unindexed predicate means more rows scanned and locked, and the locks are held until commit.",
|
|
2217
|
+
fix: `Index the ${kw}'s WHERE columns so it finds rows via an index and locks only what it changes.`,
|
|
2218
|
+
commands: [
|
|
2219
|
+
{
|
|
2220
|
+
label: "Index the predicate",
|
|
2221
|
+
sql: `CREATE INDEX ON ${rel ?? "<table>"} (<where columns>);`
|
|
2222
|
+
}
|
|
2223
|
+
]
|
|
2224
|
+
});
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
if (/^(ALTER|CREATE|DROP)\b/.test(kw) && !/\bCONCURRENTLY\b/.test(upper) && !/\bSET\s+LOCK_TIMEOUT\b/.test(upper)) {
|
|
2228
|
+
add("PGX_DDL_NO_LOCK_TIMEOUT", "warn", {
|
|
2229
|
+
title: "DDL without a lock_timeout can stall the whole table",
|
|
2230
|
+
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.",
|
|
2231
|
+
cause: "A blocked ACCESS EXCLUSIVE request sits at the head of the lock queue and blocks new readers/writers too.",
|
|
2232
|
+
fix: "Set a short lock_timeout before the DDL and retry, so it fails fast instead of forming a queue.",
|
|
2233
|
+
commands: [
|
|
2234
|
+
{
|
|
2235
|
+
label: "Fail fast, then retry",
|
|
2236
|
+
sql: "SET lock_timeout = '3s';\n-- run the DDL; on timeout, retry later"
|
|
2237
|
+
}
|
|
2238
|
+
]
|
|
2239
|
+
});
|
|
2240
|
+
}
|
|
2241
|
+
return out;
|
|
2242
|
+
}
|
|
2243
|
+
function stripSql(sql) {
|
|
2244
|
+
return sql.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/--[^\n]*/g, " ").replace(/'(?:[^']|'')*'/g, "''").replace(/"(?:[^"]|"")*"/g, '"x"');
|
|
2245
|
+
}
|
|
2246
|
+
function targetTable(code, kw) {
|
|
2247
|
+
const re = kw === "DELETE" ? /\bDELETE\s+FROM\s+([A-Za-z_][\w.]*)/i : /\bUPDATE\s+(?:ONLY\s+)?([A-Za-z_][\w.]*)/i;
|
|
2248
|
+
const m = re.exec(code);
|
|
2249
|
+
return m?.[1];
|
|
2250
|
+
}
|
|
2251
|
+
function hasSeqScanOnTarget(tree, table) {
|
|
2252
|
+
let found = false;
|
|
2253
|
+
walk(tree.root, (n) => {
|
|
2254
|
+
if (n.nodeType === "Seq Scan" && (!table || n.relationName === bareName(table))) found = true;
|
|
2255
|
+
});
|
|
2256
|
+
return found;
|
|
2257
|
+
}
|
|
2258
|
+
function bareName(qualified) {
|
|
2259
|
+
const parts = qualified.split(".");
|
|
2260
|
+
return parts[parts.length - 1] ?? qualified;
|
|
2261
|
+
}
|
|
2262
|
+
|
|
1674
2263
|
// src/report/tree.ts
|
|
1675
2264
|
function treeLines(tree, glyphs) {
|
|
1676
2265
|
const lines = [];
|
|
@@ -1711,21 +2300,28 @@ function nodeSummary(node) {
|
|
|
1711
2300
|
// src/report/json.ts
|
|
1712
2301
|
var JSON_SCHEMA_VERSION = 1;
|
|
1713
2302
|
function renderJson(result, pretty = true) {
|
|
2303
|
+
return JSON.stringify(buildReport(result), null, pretty ? 2 : 0);
|
|
2304
|
+
}
|
|
2305
|
+
function buildReport(result) {
|
|
1714
2306
|
const { tree, diagnostics, bottlenecks: bottlenecks2 } = result;
|
|
1715
2307
|
const counts = { error: 0, warn: 0, info: 0 };
|
|
1716
2308
|
for (const d of diagnostics) counts[d.severity]++;
|
|
1717
|
-
|
|
2309
|
+
return {
|
|
1718
2310
|
schemaVersion: JSON_SCHEMA_VERSION,
|
|
1719
2311
|
verdict: result.verdict,
|
|
1720
2312
|
worstSeverity: result.worstSeverity,
|
|
1721
2313
|
summary: {
|
|
1722
2314
|
planningTimeMs: tree.planningTime ?? null,
|
|
1723
2315
|
executionTimeMs: executionMs(tree) ?? null,
|
|
2316
|
+
serializationTimeMs: tree.serializationTime ?? null,
|
|
1724
2317
|
hasAnalyze: tree.hasAnalyze,
|
|
1725
2318
|
hasBuffers: tree.hasBuffers,
|
|
1726
2319
|
nodeCount: flatten(tree.root).length,
|
|
1727
2320
|
findings: counts
|
|
1728
2321
|
},
|
|
2322
|
+
triggers: tree.triggers,
|
|
2323
|
+
jit: tree.jit ?? null,
|
|
2324
|
+
settings: tree.settings ?? null,
|
|
1729
2325
|
diagnostics,
|
|
1730
2326
|
bottlenecks: bottlenecks2.filter((n) => (n.metrics.selfMs ?? 0) > 0).map((n) => ({
|
|
1731
2327
|
id: n.id,
|
|
@@ -1736,9 +2332,9 @@ function renderJson(result, pretty = true) {
|
|
|
1736
2332
|
pctOfTotal: n.metrics.pctOfTotal ?? null,
|
|
1737
2333
|
totalRows: n.metrics.totalRows ?? null
|
|
1738
2334
|
})),
|
|
2335
|
+
stats: aggregateStats(tree),
|
|
1739
2336
|
plan: serializeNode(tree.root)
|
|
1740
2337
|
};
|
|
1741
|
-
return JSON.stringify(report, null, pretty ? 2 : 0);
|
|
1742
2338
|
}
|
|
1743
2339
|
function serializeNode(node) {
|
|
1744
2340
|
const { children, metrics, raw, ...fields } = node;
|
|
@@ -2031,14 +2627,15 @@ function render(result, opts) {
|
|
|
2031
2627
|
|
|
2032
2628
|
// src/index.ts
|
|
2033
2629
|
function analyze(input, options = {}) {
|
|
2034
|
-
const trees =
|
|
2630
|
+
const trees = parseExplain(input);
|
|
2035
2631
|
const tree = selectStatement(trees, options.statement);
|
|
2036
2632
|
if (options.redact) redactPlanTree(tree);
|
|
2037
2633
|
computeMetrics(tree);
|
|
2038
2634
|
const result = runAdvisor(tree, options.config ?? DEFAULT_CONFIG);
|
|
2039
|
-
const
|
|
2040
|
-
if (
|
|
2041
|
-
|
|
2635
|
+
const extra = planNotices(tree);
|
|
2636
|
+
if (options.sql) extra.push(...analyzeLocks(options.sql, tree));
|
|
2637
|
+
if (extra.length) {
|
|
2638
|
+
result.diagnostics = [...result.diagnostics, ...extra].sort(bySeverity);
|
|
2042
2639
|
result.worstSeverity = result.diagnostics.reduce(
|
|
2043
2640
|
(worst, d) => worst === null ? d.severity : maxSeverity(worst, d.severity),
|
|
2044
2641
|
null
|
|
@@ -2070,6 +2667,6 @@ function planNotices(tree) {
|
|
|
2070
2667
|
return notices;
|
|
2071
2668
|
}
|
|
2072
2669
|
|
|
2073
|
-
export { AppError, DEFAULT_CONFIG, DEFAULT_THRESHOLDS, ExitCode, FORMATS, JSON_SCHEMA_VERSION, analyze, bottlenecks, computeMetrics, executionMs, flatten, isFormat, nodeLabel, parseExplainJson, render, runAdvisor, scrubCredentials, walk };
|
|
2670
|
+
export { AppError, DEFAULT_CONFIG, DEFAULT_THRESHOLDS, ExitCode, FORMATS, JSON_SCHEMA_VERSION, analyze, analyzeLocks, bottlenecks, computeMetrics, executionMs, flatten, isFormat, nodeLabel, parseExplain, parseExplainJson, render, runAdvisor, scrubCredentials, severityAtLeast, walk };
|
|
2074
2671
|
//# sourceMappingURL=index.js.map
|
|
2075
2672
|
//# sourceMappingURL=index.js.map
|