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/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
- const inner = node.children[1];
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