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/server.js CHANGED
@@ -23,6 +23,9 @@ var AppError = class extends Error {
23
23
  if (cause !== void 0) this.cause = cause;
24
24
  }
25
25
  };
26
+ function finding(code, severity, parts) {
27
+ return { code, domain: "plan", severity, ...parts };
28
+ }
26
29
  var SEVERITY_RANK = { error: 0, warn: 1, info: 2 };
27
30
  function bySeverity(a, b) {
28
31
  return SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
@@ -37,7 +40,7 @@ function scrubCredentials(input) {
37
40
 
38
41
  // package.json
39
42
  var package_default = {
40
- version: "0.2.0"};
43
+ version: "0.3.0"};
41
44
 
42
45
  // src/diagnostics/catalog.ts
43
46
  var DOCS = "https://www.postgresql.org/docs/current";
@@ -798,7 +801,9 @@ var DEFAULT_THRESHOLDS = {
798
801
  correlatedLoops: 1e3,
799
802
  jitPct: 25,
800
803
  triggerPct: 10,
801
- lowCacheHitRatio: 0.9
804
+ lowCacheHitRatio: 0.9,
805
+ limitDiscardRows: 1e4,
806
+ staleStatsModRatio: 0.2
802
807
  };
803
808
  var DEFAULT_CONFIG = {
804
809
  thresholds: { ...DEFAULT_THRESHOLDS },
@@ -1214,6 +1219,10 @@ function normalizeNode(raw, nextId) {
1214
1219
  diskUsage: num(raw, "Disk Usage"),
1215
1220
  exactHeapBlocks: num(raw, "Exact Heap Blocks"),
1216
1221
  lossyHeapBlocks: num(raw, "Lossy Heap Blocks"),
1222
+ cacheHits: num(raw, "Cache Hits"),
1223
+ cacheMisses: num(raw, "Cache Misses"),
1224
+ cacheEvictions: num(raw, "Cache Evictions"),
1225
+ cacheOverflows: num(raw, "Cache Overflows"),
1217
1226
  sharedHitBlocks: num(raw, "Shared Hit Blocks"),
1218
1227
  sharedReadBlocks: num(raw, "Shared Read Blocks"),
1219
1228
  sharedDirtiedBlocks: num(raw, "Shared Dirtied Blocks"),
@@ -1561,8 +1570,11 @@ var cartesianProduct = {
1561
1570
  check(node, ctx) {
1562
1571
  if (node.nodeType !== "Nested Loop") return [];
1563
1572
  if (node.joinFilter) return [];
1564
- const inner = node.children[1];
1573
+ let inner = node.children[1];
1565
1574
  if (!inner) return [];
1575
+ while ((inner.nodeType === "Memoize" || inner.nodeType === "Materialize") && inner.children[0]) {
1576
+ inner = inner.children[0];
1577
+ }
1566
1578
  if (inner.indexCond || inner.recheckCond) return [];
1567
1579
  const outer = node.children[0];
1568
1580
  if (!outer) return [];
@@ -1881,6 +1893,47 @@ var indexOnlyHeapFetches = {
1881
1893
  }
1882
1894
  };
1883
1895
 
1896
+ // src/advisor/rules/limit-large-offset.ts
1897
+ var limitLargeOffset = {
1898
+ id: "PGX_LIMIT_LARGE_OFFSET",
1899
+ title: "LIMIT discards a large prefix (OFFSET pagination)",
1900
+ defaultSeverity: "warn",
1901
+ requiresAnalyze: true,
1902
+ check(node, ctx) {
1903
+ if (node.nodeType !== "Limit") return [];
1904
+ const child = outerChild(node);
1905
+ const emitted = node.metrics.totalRows;
1906
+ const produced = child?.metrics.totalRows;
1907
+ if (emitted === void 0 || produced === void 0) return [];
1908
+ const discarded = produced - emitted;
1909
+ if (discarded < ctx.thresholds.limitDiscardRows) return [];
1910
+ const rel = child?.relationName ?? "the input";
1911
+ return [
1912
+ makeFinding(limitLargeOffset, ctx, node, {
1913
+ title: `LIMIT discarded ${fmtInt(discarded)} rows before returning ${fmtInt(emitted)}`,
1914
+ 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.`,
1915
+ 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)).",
1916
+ remediation: {
1917
+ 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.",
1918
+ steps: [
1919
+ "Order by a unique (or tie-broken) key, e.g. ORDER BY created_at, id.",
1920
+ "Pass the last row's key from the previous page instead of an OFFSET.",
1921
+ "Index the sort key so the WHERE clause seeks directly to the page start."
1922
+ ],
1923
+ commands: [
1924
+ {
1925
+ label: "Keyset pagination instead of OFFSET",
1926
+ sql: "SELECT \u2026 FROM t WHERE (created_at, id) > ($last_created_at, $last_id) ORDER BY created_at, id LIMIT 50;"
1927
+ }
1928
+ ]
1929
+ },
1930
+ docsUrl: `${DOCS2}/queries-limit.html`,
1931
+ meta: { discarded: Math.round(discarded), emitted: Math.round(emitted) }
1932
+ })
1933
+ ];
1934
+ }
1935
+ };
1936
+
1884
1937
  // src/advisor/rules/low-cache-hit.ts
1885
1938
  var MIN_READ_BLOCKS = 1e3;
1886
1939
  var lowCacheHit = {
@@ -1927,6 +1980,46 @@ var lowCacheHit = {
1927
1980
  }
1928
1981
  };
1929
1982
 
1983
+ // src/advisor/rules/memoize-evictions.ts
1984
+ var memoizeEvictions = {
1985
+ id: "PGX_MEMOIZE_EVICTIONS",
1986
+ title: "Memoize cache is thrashing",
1987
+ defaultSeverity: "warn",
1988
+ requiresAnalyze: true,
1989
+ check(node, ctx) {
1990
+ if (node.nodeType !== "Memoize") return [];
1991
+ const hits = node.cacheHits ?? 0;
1992
+ const evictions = node.cacheEvictions ?? 0;
1993
+ const overflows = node.cacheOverflows ?? 0;
1994
+ const thrashing = evictions > hits;
1995
+ if (!thrashing && overflows === 0) return [];
1996
+ return [
1997
+ makeFinding(memoizeEvictions, ctx, node, {
1998
+ title: overflows > 0 ? `Memoize cache overflowed ${fmtInt(overflows)} time(s)` : `Memoize evicted ${fmtInt(evictions)} entries against ${fmtInt(hits)} hits`,
1999
+ 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.`,
2000
+ 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.",
2001
+ remediation: {
2002
+ 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.",
2003
+ steps: [
2004
+ "Estimate the distinct keys: the planner sizes the cache from ndistinct of the join key.",
2005
+ "Raise work_mem (or hash_mem_multiplier on PG 15+) for this workload and re-run.",
2006
+ "If the key space is genuinely huge, an index on the inner side may beat Memoize \u2014 compare with enable_memoize = off."
2007
+ ],
2008
+ commands: [
2009
+ { label: "More cache memory for this session", sql: "SET work_mem = '64MB';" },
2010
+ {
2011
+ label: "Compare the plan without Memoize",
2012
+ sql: "SET enable_memoize = off; EXPLAIN ANALYZE <query>;"
2013
+ }
2014
+ ]
2015
+ },
2016
+ docsUrl: `${DOCS2}/runtime-config-resource.html`,
2017
+ meta: { hits, evictions, overflows }
2018
+ })
2019
+ ];
2020
+ }
2021
+ };
2022
+
1930
2023
  // src/advisor/rules/nested-loop-large-outer.ts
1931
2024
  var nestedLoopLargeOuter = {
1932
2025
  id: "PGX_NESTED_LOOP_LARGE_OUTER",
@@ -2309,8 +2402,10 @@ var ALL_RULES = [
2309
2402
  seqScanLarge,
2310
2403
  nestedLoopLargeOuter,
2311
2404
  highFilterDiscard,
2405
+ limitLargeOffset,
2312
2406
  sortSpillDisk,
2313
2407
  hashSpillDisk,
2408
+ memoizeEvictions,
2314
2409
  correlatedSubplan,
2315
2410
  rowMisestimate,
2316
2411
  filterCouldBeIndexCond,
@@ -2338,7 +2433,7 @@ function runAdvisor(tree, config = DEFAULT_CONFIG) {
2338
2433
  if (rule.requiresAnalyze && !tree.hasAnalyze) continue;
2339
2434
  if (rule.requiresBuffers && !tree.hasBuffers) continue;
2340
2435
  for (const node of nodes) {
2341
- for (const finding of rule.check(node, ctx)) diagnostics.push(finding);
2436
+ for (const finding2 of rule.check(node, ctx)) diagnostics.push(finding2);
2342
2437
  }
2343
2438
  }
2344
2439
  diagnostics.sort(bySeverity);
@@ -3235,35 +3330,6 @@ function diffAnalyses(before, after) {
3235
3330
  };
3236
3331
  }
3237
3332
 
3238
- // src/locks/live.ts
3239
- var SQL = `
3240
- SELECT a.pid,
3241
- a.usename AS "user",
3242
- a.state,
3243
- a.wait_event_type AS "waitEventType",
3244
- a.wait_event AS "waitEvent",
3245
- EXTRACT(EPOCH FROM (now() - a.query_start)) AS "ageSeconds",
3246
- a.query,
3247
- pg_blocking_pids(a.pid) AS "blockedBy"
3248
- FROM pg_stat_activity a
3249
- WHERE a.backend_type = 'client backend' AND a.pid <> pg_backend_pid()
3250
- ORDER BY cardinality(pg_blocking_pids(a.pid)) DESC, a.query_start NULLS LAST;
3251
- `;
3252
- async function liveLocks(connection, capturedAt) {
3253
- const rows = await queryReadOnly(connection, SQL);
3254
- const sessions = rows.map((r) => ({
3255
- pid: r.pid,
3256
- user: r.user,
3257
- state: r.state,
3258
- waitEventType: r.waitEventType,
3259
- waitEvent: r.waitEvent,
3260
- ageSeconds: r.ageSeconds == null ? null : Number(r.ageSeconds),
3261
- query: r.query,
3262
- blockedBy: r.blockedBy ?? []
3263
- }));
3264
- return { sessions, blocked: sessions.filter((s) => s.blockedBy.length > 0), capturedAt };
3265
- }
3266
-
3267
3333
  // src/server/schema.ts
3268
3334
  var CATALOG_SQL = `
3269
3335
  SELECT n.nspname AS schema,
@@ -3284,7 +3350,7 @@ async function catalog(connection) {
3284
3350
  );
3285
3351
  return rows.map((r) => ({ schema: r.schema, name: r.name, columns: r.columns ?? [] }));
3286
3352
  }
3287
- var SQL2 = `
3353
+ var SQL = `
3288
3354
  SELECT c.relname AS relation,
3289
3355
  c.reltuples::bigint AS "estRows",
3290
3356
  pg_total_relation_size(c.oid) AS "totalBytes",
@@ -3295,7 +3361,9 @@ SELECT c.relname AS relation,
3295
3361
  s.last_vacuum AS "lastVacuum",
3296
3362
  s.last_autovacuum AS "lastAutovacuum",
3297
3363
  s.last_analyze AS "lastAnalyze",
3298
- s.last_autoanalyze AS "lastAutoanalyze"
3364
+ s.last_autoanalyze AS "lastAutoanalyze",
3365
+ s.n_mod_since_analyze AS "modSinceAnalyze",
3366
+ s.n_live_tup AS "liveTup"
3299
3367
  FROM pg_class c
3300
3368
  LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
3301
3369
  WHERE c.relkind IN ('r', 'p') AND c.relname = ANY($1);
@@ -3304,7 +3372,7 @@ var toNum = (v) => v == null ? null : Number(v);
3304
3372
  async function relationStats(connection, relations) {
3305
3373
  const names = [...new Set(relations.filter(Boolean))];
3306
3374
  if (names.length === 0) return [];
3307
- const rows = await queryReadOnly(connection, SQL2, [names]);
3375
+ const rows = await queryReadOnly(connection, SQL, [names]);
3308
3376
  return rows.map((r) => ({
3309
3377
  relation: r.relation,
3310
3378
  estRows: toNum(r.estRows),
@@ -3314,9 +3382,106 @@ async function relationStats(connection, relations) {
3314
3382
  lastVacuum: r.lastVacuum,
3315
3383
  lastAutovacuum: r.lastAutovacuum,
3316
3384
  lastAnalyze: r.lastAnalyze,
3317
- lastAutoanalyze: r.lastAutoanalyze
3385
+ lastAutoanalyze: r.lastAutoanalyze,
3386
+ modSinceAnalyze: toNum(r.modSinceAnalyze),
3387
+ liveTup: toNum(r.liveTup)
3318
3388
  }));
3319
3389
  }
3390
+
3391
+ // src/diagnostics/stale-stats.ts
3392
+ var RULE_ID = "PGX_STALE_STATISTICS";
3393
+ var DOCS4 = "https://www.postgresql.org/docs/current/routine-vacuuming.html#VACUUM-FOR-STATISTICS";
3394
+ var MIN_ROWS = 1e3;
3395
+ function staleStatsFindings(stats, config) {
3396
+ if (config.rules[RULE_ID]?.enabled === false) return [];
3397
+ const severity = config.rules[RULE_ID]?.severity ?? "warn";
3398
+ const ratioLimit = config.thresholds.staleStatsModRatio;
3399
+ const out = [];
3400
+ for (const s of stats) {
3401
+ const rows = s.liveTup ?? s.estRows ?? 0;
3402
+ if (rows < MIN_ROWS) continue;
3403
+ const neverAnalyzed = !s.lastAnalyze && !s.lastAutoanalyze;
3404
+ const modRatio = s.modSinceAnalyze != null && rows > 0 ? s.modSinceAnalyze / rows : 0;
3405
+ if (!neverAnalyzed && modRatio < ratioLimit) continue;
3406
+ out.push(
3407
+ finding(RULE_ID, severity, {
3408
+ title: neverAnalyzed ? `Table ${s.relation} has never been analyzed` : `Planner statistics on ${s.relation} are stale`,
3409
+ detail: neverAnalyzed ? `${s.relation} (~${Math.round(rows).toLocaleString()} rows) has no planner statistics \u2014 pg_stat_user_tables shows no manual or auto ANALYZE.` : `${s.modSinceAnalyze?.toLocaleString()} rows of ${s.relation} changed since its last ANALYZE (${(modRatio * 100).toFixed(0)}% of ~${Math.round(rows).toLocaleString()} live rows).`,
3410
+ cause: "The planner chooses plans from per-table statistics. When they are missing or stale, row estimates drift, which cascades into bad join orders, wrong scan types, and misestimates like PGX_ROW_MISESTIMATE.",
3411
+ remediation: {
3412
+ summary: `Run ANALYZE on ${s.relation}, and if it keeps going stale, lower its autovacuum analyze threshold.`,
3413
+ steps: [
3414
+ "ANALYZE the table now to refresh statistics.",
3415
+ "If the table churns heavily, tune per-table autovacuum settings so auto-analyze keeps up."
3416
+ ],
3417
+ commands: [
3418
+ { label: "Refresh statistics", sql: `ANALYZE ${s.relation};` },
3419
+ {
3420
+ label: "Analyze more eagerly on churny tables",
3421
+ sql: `ALTER TABLE ${s.relation} SET (autovacuum_analyze_scale_factor = 0.02);`
3422
+ }
3423
+ ]
3424
+ },
3425
+ docsUrl: DOCS4,
3426
+ meta: {
3427
+ relation: s.relation,
3428
+ modSinceAnalyze: s.modSinceAnalyze ?? 0,
3429
+ liveTup: s.liveTup ?? 0
3430
+ }
3431
+ })
3432
+ );
3433
+ }
3434
+ return out;
3435
+ }
3436
+ async function checkStaleStats(connection, result, config) {
3437
+ try {
3438
+ const relations = [
3439
+ ...new Set(
3440
+ flatten(result.tree.root).map((n) => n.relationName).filter((r) => !!r)
3441
+ )
3442
+ ];
3443
+ if (!relations.length) return;
3444
+ appendFindings(result, staleStatsFindings(await relationStats(connection, relations), config));
3445
+ } catch {
3446
+ }
3447
+ }
3448
+ function appendFindings(result, extra) {
3449
+ if (!extra.length) return;
3450
+ result.diagnostics = [...result.diagnostics, ...extra].sort(bySeverity);
3451
+ result.worstSeverity = result.diagnostics.reduce(
3452
+ (worst, d) => worst === null ? d.severity : maxSeverity(worst, d.severity),
3453
+ null
3454
+ );
3455
+ }
3456
+
3457
+ // src/locks/live.ts
3458
+ var SQL2 = `
3459
+ SELECT a.pid,
3460
+ a.usename AS "user",
3461
+ a.state,
3462
+ a.wait_event_type AS "waitEventType",
3463
+ a.wait_event AS "waitEvent",
3464
+ EXTRACT(EPOCH FROM (now() - a.query_start)) AS "ageSeconds",
3465
+ a.query,
3466
+ pg_blocking_pids(a.pid) AS "blockedBy"
3467
+ FROM pg_stat_activity a
3468
+ WHERE a.backend_type = 'client backend' AND a.pid <> pg_backend_pid()
3469
+ ORDER BY cardinality(pg_blocking_pids(a.pid)) DESC, a.query_start NULLS LAST;
3470
+ `;
3471
+ async function liveLocks(connection, capturedAt) {
3472
+ const rows = await queryReadOnly(connection, SQL2);
3473
+ const sessions = rows.map((r) => ({
3474
+ pid: r.pid,
3475
+ user: r.user,
3476
+ state: r.state,
3477
+ waitEventType: r.waitEventType,
3478
+ waitEvent: r.waitEvent,
3479
+ ageSeconds: r.ageSeconds == null ? null : Number(r.ageSeconds),
3480
+ query: r.query,
3481
+ blockedBy: r.blockedBy ?? []
3482
+ }));
3483
+ return { sessions, blocked: sessions.filter((s) => s.blockedBy.length > 0), capturedAt };
3484
+ }
3320
3485
  function dataDir() {
3321
3486
  return process.env.PGEXPLAIN_DATA_DIR ?? join(homedir(), ".pgexplain");
3322
3487
  }
@@ -3694,6 +3859,7 @@ function apiRoutes(store, config) {
3694
3859
  sql: statement,
3695
3860
  config: config.current
3696
3861
  });
3862
+ await checkStaleStats(connection, result, config.current);
3697
3863
  const report = {
3698
3864
  ...buildReport(result),
3699
3865
  server: { major: exec.caps.major, omitted: exec.omitted }
@@ -3709,7 +3875,11 @@ function apiRoutes(store, config) {
3709
3875
  const afterPlan = body.afterPlan ?? planTextOf(store, body.afterId);
3710
3876
  const before = analyze(beforePlan, { redact: body.redact });
3711
3877
  const after = analyze(afterPlan, { redact: body.redact });
3712
- return c.json(diffAnalyses(before, after));
3878
+ return c.json({
3879
+ ...diffAnalyses(before, after),
3880
+ beforePlan: serializeNode(before.tree.root),
3881
+ afterPlan: serializeNode(after.tree.root)
3882
+ });
3713
3883
  });
3714
3884
  api.post("/api/export", async (c) => {
3715
3885
  const body = validate(ExportBodySchema, await c.req.json().catch(() => ({})));