pgexplain 0.2.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 +102 -6
- package/dist/cli.js +355 -6
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.js +98 -3
- package/dist/index.js.map +1 -1
- package/dist/server.js +208 -38
- package/dist/server.js.map +1 -1
- package/dist/web/assets/{PlanGraph-0P3xGm2e.js → PlanGraph-CD8gYPCY.js} +1 -1
- package/dist/web/assets/index-D3fMyvfo.js +237 -0
- package/dist/web/assets/index-p4QC4qQe.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +4 -2
- package/dist/web/assets/index-BqB6p5Pn.js +0 -227
- package/dist/web/assets/index-ByEFSLsN.css +0 -1
package/dist/index.d.ts
CHANGED
|
@@ -125,6 +125,10 @@ interface PlanNode {
|
|
|
125
125
|
diskUsage?: number;
|
|
126
126
|
exactHeapBlocks?: number;
|
|
127
127
|
lossyHeapBlocks?: number;
|
|
128
|
+
cacheHits?: number;
|
|
129
|
+
cacheMisses?: number;
|
|
130
|
+
cacheEvictions?: number;
|
|
131
|
+
cacheOverflows?: number;
|
|
128
132
|
sharedHitBlocks?: number;
|
|
129
133
|
sharedReadBlocks?: number;
|
|
130
134
|
sharedDirtiedBlocks?: number;
|
|
@@ -198,6 +202,8 @@ interface Thresholds {
|
|
|
198
202
|
jitPct: number;
|
|
199
203
|
triggerPct: number;
|
|
200
204
|
lowCacheHitRatio: number;
|
|
205
|
+
limitDiscardRows: number;
|
|
206
|
+
staleStatsModRatio: number;
|
|
201
207
|
}
|
|
202
208
|
interface AnalysisContext {
|
|
203
209
|
tree: PlanTree;
|
|
@@ -313,6 +319,8 @@ declare class AppError extends Error {
|
|
|
313
319
|
readonly exitCode: ExitCode;
|
|
314
320
|
constructor(diagnostic: Diagnostic, exitCode: ExitCode, cause?: unknown);
|
|
315
321
|
}
|
|
322
|
+
/** True when `s` is at least as severe as `threshold` (error ≥ warn ≥ info). */
|
|
323
|
+
declare function severityAtLeast(s: Severity, threshold: Severity): boolean;
|
|
316
324
|
/**
|
|
317
325
|
* Remove secrets from any string before it is logged, shown, or written.
|
|
318
326
|
* Targets:
|
|
@@ -360,4 +368,4 @@ interface AnalyzeOptions {
|
|
|
360
368
|
/** Parse → (redact) → compute metrics → run advisor (+lock advisor) → attach notices. */
|
|
361
369
|
declare function analyze(input: string, options?: AnalyzeOptions): AnalysisResult;
|
|
362
370
|
|
|
363
|
-
export { type AnalysisContext, type AnalysisResult, type AnalyzeOptions, AppError, DEFAULT_CONFIG, DEFAULT_THRESHOLDS, type Diagnostic, type DiagnosticLocation, type Domain, ExitCode, FORMATS, type Format, JSON_SCHEMA_VERSION, type JitInfo, type NodeMetrics, type PgExplainConfig, type PlanNode, type PlanTree, type RawPlan, type Remediation, type RemediationCommand, type RenderOptions, type Rule, type Severity, type Thresholds, type TriggerInfo, type WorkerStat, analyze, analyzeLocks, bottlenecks, computeMetrics, executionMs, flatten, isFormat, nodeLabel, parseExplain, parseExplainJson, render, runAdvisor, scrubCredentials, walk };
|
|
371
|
+
export { type AnalysisContext, type AnalysisResult, type AnalyzeOptions, AppError, DEFAULT_CONFIG, DEFAULT_THRESHOLDS, type Diagnostic, type DiagnosticLocation, type Domain, ExitCode, FORMATS, type Format, JSON_SCHEMA_VERSION, type JitInfo, type NodeMetrics, type PgExplainConfig, type PlanNode, type PlanTree, type RawPlan, type Remediation, type RemediationCommand, type RenderOptions, type Rule, type Severity, type Thresholds, type TriggerInfo, type WorkerStat, analyze, analyzeLocks, bottlenecks, computeMetrics, executionMs, flatten, isFormat, nodeLabel, parseExplain, parseExplainJson, render, runAdvisor, scrubCredentials, severityAtLeast, walk };
|
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,7 +439,9 @@ 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 },
|
|
@@ -852,6 +857,10 @@ function normalizeNode(raw, nextId) {
|
|
|
852
857
|
diskUsage: num(raw, "Disk Usage"),
|
|
853
858
|
exactHeapBlocks: num(raw, "Exact Heap Blocks"),
|
|
854
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"),
|
|
855
864
|
sharedHitBlocks: num(raw, "Shared Hit Blocks"),
|
|
856
865
|
sharedReadBlocks: num(raw, "Shared Read Blocks"),
|
|
857
866
|
sharedDirtiedBlocks: num(raw, "Shared Dirtied Blocks"),
|
|
@@ -1199,8 +1208,11 @@ var cartesianProduct = {
|
|
|
1199
1208
|
check(node, ctx) {
|
|
1200
1209
|
if (node.nodeType !== "Nested Loop") return [];
|
|
1201
1210
|
if (node.joinFilter) return [];
|
|
1202
|
-
|
|
1211
|
+
let inner = node.children[1];
|
|
1203
1212
|
if (!inner) return [];
|
|
1213
|
+
while ((inner.nodeType === "Memoize" || inner.nodeType === "Materialize") && inner.children[0]) {
|
|
1214
|
+
inner = inner.children[0];
|
|
1215
|
+
}
|
|
1204
1216
|
if (inner.indexCond || inner.recheckCond) return [];
|
|
1205
1217
|
const outer = node.children[0];
|
|
1206
1218
|
if (!outer) return [];
|
|
@@ -1519,6 +1531,47 @@ var indexOnlyHeapFetches = {
|
|
|
1519
1531
|
}
|
|
1520
1532
|
};
|
|
1521
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
|
+
|
|
1522
1575
|
// src/advisor/rules/low-cache-hit.ts
|
|
1523
1576
|
var MIN_READ_BLOCKS = 1e3;
|
|
1524
1577
|
var lowCacheHit = {
|
|
@@ -1565,6 +1618,46 @@ var lowCacheHit = {
|
|
|
1565
1618
|
}
|
|
1566
1619
|
};
|
|
1567
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
|
+
|
|
1568
1661
|
// src/advisor/rules/nested-loop-large-outer.ts
|
|
1569
1662
|
var nestedLoopLargeOuter = {
|
|
1570
1663
|
id: "PGX_NESTED_LOOP_LARGE_OUTER",
|
|
@@ -1947,8 +2040,10 @@ var ALL_RULES = [
|
|
|
1947
2040
|
seqScanLarge,
|
|
1948
2041
|
nestedLoopLargeOuter,
|
|
1949
2042
|
highFilterDiscard,
|
|
2043
|
+
limitLargeOffset,
|
|
1950
2044
|
sortSpillDisk,
|
|
1951
2045
|
hashSpillDisk,
|
|
2046
|
+
memoizeEvictions,
|
|
1952
2047
|
correlatedSubplan,
|
|
1953
2048
|
rowMisestimate,
|
|
1954
2049
|
filterCouldBeIndexCond,
|
|
@@ -2572,6 +2667,6 @@ function planNotices(tree) {
|
|
|
2572
2667
|
return notices;
|
|
2573
2668
|
}
|
|
2574
2669
|
|
|
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 };
|
|
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 };
|
|
2576
2671
|
//# sourceMappingURL=index.js.map
|
|
2577
2672
|
//# sourceMappingURL=index.js.map
|