turbine-orm 0.56.0 → 0.57.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.
@@ -4,8 +4,31 @@
4
4
  * `index-advisor.ts` answers "which relation probes have no index" from pure
5
5
  * topology. `index-stats.ts` answers "is adding that index worth it" from live
6
6
  * statistics. This module answers the third question on the SAME columns: "this
7
- * column IS indexed, and its value distribution makes a NAMED prepared
8
- * statement's generic plan unsafe".
7
+ * column's value distribution makes a NAMED prepared statement's generic plan
8
+ * unsafe".
9
+ *
10
+ * TWO BRANCHES, one skeleton. They differ in what the CUSTOM plan does, which is
11
+ * what decides both the boundary and the units of the damage:
12
+ *
13
+ * - `sparse-value`: the filter column IS served by a btree, so the custom plan
14
+ * takes a bitmap scan over the rare value's own rows and the boundary is a
15
+ * page comparison. This is the original rule and is unchanged.
16
+ * - `unindexed-filter`: NO index can serve `col = $1`, so the custom plan's
17
+ * only alternative is a seq scan + top-N sort. Added after an earlier
18
+ * revision dropped every unindexed column BEFORE counting it as considered,
19
+ * which put a whole class of real divergence outside the scored population
20
+ * and outside the notices too. See the branch's own header below.
21
+ *
22
+ * "Served by an index" here means a VALID, non-partial, non-expression index
23
+ * whose leading key column is the filter column and whose access method is btree
24
+ * or HASH. Hash belongs in the first branch, not the second: a hash index gives
25
+ * the planner the same bitmap-over-this-value's-rows path a btree does, which is
26
+ * the plan the crossover is derived against. Measured on the fixture below with
27
+ * a hash index on the filter column, the custom plan is a Bitmap Heap Scan
28
+ * reading 7 buffers, not the 247-page seq scan the second branch would have
29
+ * described. Any OTHER access method leading with the column (brin, gin, gist,
30
+ * spgist) is not scored at all and is reported as a notice: those paths are
31
+ * lossy or shaped differently, and neither branch's model describes them.
9
32
  *
10
33
  * THE MECHANISM. Postgres may promote a named prepared statement to a GENERIC
11
34
  * plan from its sixth execution onward, and ONLY when the generic plan's
@@ -15,13 +38,37 @@
15
38
  * - an unknown equality `col = $1` is estimated as reltuples / n_distinct;
16
39
  * - an unknown `LIMIT $n` is estimated as 10% of the CHILD node's row estimate.
17
40
  *
18
- * THE ONE SHAPE THIS MODELS, stated narrowly on purpose. A read shaped
19
- * `WHERE col = $1 ORDER BY <other indexed column> LIMIT $n`, where the generic
20
- * estimate for `col = $1` sits ABOVE the plan boundary (so the generic plan
21
- * keeps the ordered index scan and filters) while some real values sit far
22
- * BELOW it (so for those values the ordered scan has to walk a large fraction
23
- * of the table before it accumulates one page of matches, where the custom
24
- * plan takes a bitmap scan over the value's own rows and sorts them).
41
+ * THE SHAPE BOTH BRANCHES MODEL, stated narrowly on purpose. A read shaped
42
+ * `WHERE col = $1 ORDER BY <other indexed column> LIMIT $n`, where the promoted
43
+ * generic plan keeps the ordered index scan and filters, while for the table's
44
+ * rarest values the custom planner picks something else entirely.
45
+ *
46
+ * - `sparse-value` (the filter column is indexed): the generic estimate for
47
+ * `col = $1` sits ABOVE the plan boundary while some real values sit far
48
+ * BELOW it, so for those values the ordered scan walks a large fraction of
49
+ * the table before it accumulates one page of matches, where the custom plan
50
+ * takes a bitmap scan over the value's own rows and sorts them.
51
+ * - `unindexed-filter` (no equality path on the filter column): the custom plan
52
+ * takes a seq scan + top-N sort, which is bounded by the table's PAGES. The
53
+ * generic plan still takes the ordered index walk and, because it cannot see
54
+ * the value is rare, it walks nearly every TUPLE before it fills the LIMIT.
55
+ * Measured on the fixture below: 250 buffers against 20,074, and Postgres
56
+ * promoted it after five executions of the rare value.
57
+ *
58
+ * THE FIXTURE every `unindexed-filter` number in this file was measured on,
59
+ * printed once here so each number is checkable rather than asserted. PostgreSQL
60
+ * 16, warm cache, `synchronize_seqscans`, `max_parallel_workers_per_gather` and
61
+ * `jit` all off:
62
+ *
63
+ * CREATE TABLE t (id int PRIMARY KEY, organization_id int NOT NULL, payload text NOT NULL);
64
+ * INSERT INTO t SELECT g, <bucket(g)>, repeat('p', 60)
65
+ * FROM generate_series(1, N) g ORDER BY (g * 2654435761::bigint) % 1000003;
66
+ *
67
+ * which is 81 rows per page (20,000 rows in 247 pages) and, because of the
68
+ * hash-ordered INSERT, a primary key UNCORRELATED with physical position. The
69
+ * read is `WHERE organization_id = $1 ORDER BY id LIMIT $2` at limit 20, on the
70
+ * rarest bucket. The same DDL is what `plan-divergence-unindexed.integration.test.ts`
71
+ * builds, so the ladders below can be re-run from the repo.
25
72
  *
26
73
  * WHAT THIS DELIBERATELY DOES NOT MODEL, because it was tried and it did not
27
74
  * work. An earlier revision carried a second rule for the opposite direction (a
@@ -42,6 +89,26 @@
42
89
  * shapes where a generic plan is the better one. A clean report is not evidence
43
90
  * of immunity.
44
91
  *
92
+ * A SECOND, STRUCTURAL blind spot bounds both branches: the candidate set is
93
+ * relation probes plus leading index columns. A plain filter column that is
94
+ * neither an FK nor indexed (a `status`, a `deleted_at`, a tenant column with no
95
+ * FK constraint) is invisible to this check in EITHER branch. Feeding the index
96
+ * advisor's recommendations in does not fix it, because the advisor derives from
97
+ * the same relation topology; closing it would need a workload source
98
+ * (pg_stat_statements or _turbine_metrics). The report says so where it prints
99
+ * how many columns were scored, rather than letting that count imply coverage.
100
+ *
101
+ * A THIRD blind spot, specific to `unindexed-filter` and disclosed on the
102
+ * finding rather than hidden: the branch cannot tell a heap that is in near-exact
103
+ * {@link PlanDivergenceFinding.orderColumn} order from one that is not, and the
104
+ * two read 1.2x and 80x on otherwise identical fixtures. The ordering column's
105
+ * pg_stats.correlation separates them, but only at the fifth decimal place of a
106
+ * sampled statistic, so it is reported and used to QUALIFY the finding
107
+ * ({@link PlanDivergenceFinding.heapNearlyOrdered}), never to suppress one. Its
108
+ * recall is bounded on the other side by a constant: gate 1 admits only a rarest
109
+ * bucket below the assumed LIMIT, which declines a measured 81x flip at bucket
110
+ * 60 (see the gate).
111
+ *
45
112
  * EXPOSURE IS NOT AN INCIDENT. A finding says the DISTRIBUTION admits a
46
113
  * damaging flip. It does NOT say the backend is choosing the bad plan today:
47
114
  * `auto` promotes only when the generic plan's ESTIMATED cost is not worse than
@@ -51,14 +118,22 @@
51
118
  * compares the two plans: the counter is the only thing that establishes real
52
119
  * exposure.
53
120
  *
54
- * TWO UNITS, and mixing them up is what made the removed rule wrong. The
55
- * PLANNER costs the boundary in PAGES (an ordered index scan reads about
56
- * limit / matching of the table's pages before it fills the limit), which is why
57
- * {@link crossoverRows} is derived from relpages and why measured flip points
58
- * track it. The DAMAGE a user feels is also read in pages here, deliberately
59
- * conservatively; when the ordering index is not correlated with heap order,
60
- * each row examined is a separate block access and the true buffer count is
61
- * larger, sometimes by an order of magnitude.
121
+ * TWO UNITS, and mixing them up is what made the removed rule wrong. In the
122
+ * `sparse-value` branch the PLANNER costs the boundary in PAGES (an ordered
123
+ * index scan reads about limit / matching of the table's pages before it fills
124
+ * the limit), which is why {@link crossoverRows} is derived from relpages and
125
+ * why measured flip points track it. The DAMAGE is also read in pages there,
126
+ * deliberately conservatively; when the ordering index is not correlated with
127
+ * heap order, each row examined is a separate block access and the true buffer
128
+ * count is larger, sometimes by an order of magnitude.
129
+ *
130
+ * The `unindexed-filter` branch reads damage in TUPLES on the generic side and
131
+ * PAGES on the custom side, because that is what the two plans actually are: an
132
+ * index-order heap fetch per row against one sequential pass. Carrying
133
+ * {@link PlanDivergenceFinding.walkPages} into it would under-report the generic
134
+ * side by exactly the rows-per-page factor, which is the entire finding. That is
135
+ * why the page-shaped fields are left UNSET on that branch instead of being
136
+ * filled with numbers from a model that does not apply.
62
137
  *
63
138
  * FRESHNESS GATE. The cost-tier half of doctor gates on `stats_reset` age,
64
139
  * because it normalizes WRITE COUNTERS by it. Nothing here reads a counter: every
@@ -106,17 +181,71 @@ export const PLAN_DIVERGENCE_THRESHOLDS = {
106
181
  * This replaced a `relpages >= 1000` table-size floor, which was wrong and
107
182
  * measurably so: a wrong plan on a SMALL table is not cheap, because an ordered
108
183
  * index scan's cost is driven by how much of the table it walks, not by how big
109
- * the table is. On a 285-page / 18,500-row fixture the generic plan read 336
110
- * buffers against the custom plan's 7 (48x), and the old floor dropped that
111
- * column without even counting it as considered.
184
+ * the table is. Fixture, stated in full so the number is checkable:
185
+ *
186
+ * CREATE TABLE t (id int PRIMARY KEY, organization_id int NOT NULL, payload text NOT NULL);
187
+ * -- 18,500 rows, repeat('p', 85) payload => 285 relpages, buckets
188
+ * -- 9,000 / 5,000 / 4,498 / 2, CREATE INDEX ON t (organization_id).
189
+ * -- read: WHERE organization_id = $1 ORDER BY id LIMIT $2, rarest value, limit 20.
190
+ *
191
+ * Inserted in `id` order the generic plan read 337 buffers against the custom
192
+ * plan's 6 (56x); inserted in hash order, the same shape read 18,574 against 7.
193
+ * The old floor dropped that column without even counting it as considered.
112
194
  */
113
195
  minWalkPages: 50,
114
196
  /**
115
197
  * ...and it must walk at least this FRACTION of the table. The two gates are
116
198
  * different questions (absolute cost, and how badly the plan is mismatched to
117
199
  * the value), and a finding needs both.
200
+ *
201
+ * `sparse-value` only. In the `unindexed-filter` branch the walk is the whole
202
+ * table by construction (gate 1 there puts the rarest bucket below the LIMIT,
203
+ * so the ordered scan can never fill it), which makes this gate vacuous.
204
+ * Reusing it there would read as a gate that did work.
118
205
  */
119
206
  minWalkFraction: 0.1,
207
+ /**
208
+ * `unindexed-filter` only: how many TUPLES the promoted plan must walk before
209
+ * this is worth a user's attention.
210
+ *
211
+ * Amplification in that branch is rows-per-page and nothing else, so it is
212
+ * size independent and only an absolute floor separates a real finding from
213
+ * noise. Measured on the fixture in the module header, warm cache, rarest
214
+ * bucket 2, limit 20 (rows / relpages / custom buffers / custom ms / generic
215
+ * buffers / generic ms):
216
+ *
217
+ * 1,500 / 19 / 22 / 0.14 / 1,506 / 0.28
218
+ * 5,000 / 62 / 65 / 0.17 / 5,020 / 0.64
219
+ * 10,000 / 124 / 127 / 0.29 / 10,038 / 2.20
220
+ * 20,000 / 247 / 250 / 0.83 / 20,074 / 5.84
221
+ * 200,000 / 2,470 / 2,473 / 6.06 / 200,587 / 63.07
222
+ *
223
+ * (An earlier revision of this table carried a page column about 8% high, from
224
+ * a wider row than any fixture in this repo. The numbers above come from the
225
+ * DDL printed in the module header and nothing else.)
226
+ *
227
+ * 10,000 tuples is where the warm-cache delta reaches the "a millisecond or
228
+ * two" line {@link minWalkPages} is calibrated to, and where the cold-cache
229
+ * exposure (10,000 candidate random page accesses) stops being trivial. Not
230
+ * gated on pages instead: at 10,000 rows any realistic row width already
231
+ * clears 50 pages, and a narrow table with 10,000 rows in 40 pages is still a
232
+ * 250x flip.
233
+ */
234
+ minGenericTupleWalk: 10_000,
235
+ /**
236
+ * `unindexed-filter` only, and it QUALIFIES a finding rather than gating it:
237
+ * at or above this correlation between the heap and {@link
238
+ * PlanDivergenceFinding.orderColumn}, the flip is real but reads ~1x, so the
239
+ * finding is printed with that stated instead of with a rows-per-page ratio
240
+ * it would not reach.
241
+ *
242
+ * Fitted to the measured ladder on {@link
243
+ * PlanDivergenceFinding.orderColumnCorrelation}: 0.99998 (one page of local
244
+ * disorder) reads 3.1x and 0.99993 (two pages) reads 41x, so the boundary is
245
+ * between them. That is the fifth decimal place of a SAMPLED statistic, which
246
+ * is exactly why nothing is suppressed on it.
247
+ */
248
+ nearExactOrderCorrelation: 0.99995,
120
249
  };
121
250
  // ---------------------------------------------------------------------------
122
251
  // Candidate enumeration (schema-only)
@@ -136,6 +265,14 @@ function isUniqueOnColumn(meta, column) {
136
265
  * Purely schema-derived, so the collector knows what to read BEFORE any stats
137
266
  * exist. Whether the column is really served by a btree is decided later,
138
267
  * against the live index list.
268
+ *
269
+ * NOTE, because it is the obvious next idea and it is a no-op: feeding
270
+ * `findMissingRelationIndexes`' recommended columns in here adds nothing. Both
271
+ * derive from the same relation topology, this walker already enumerates every
272
+ * single-column relation probe whether or not it is indexed, and the advisor's
273
+ * only extra members are COMPOSITE probes, which this check deliberately does
274
+ * not model. The unindexed population was never missing from the candidate set;
275
+ * it was dropped later, by a shape gate. That gate is where it was fixed.
139
276
  */
140
277
  export function collectDivergenceCandidateColumns(schema) {
141
278
  const seen = new Set();
@@ -183,53 +320,126 @@ export function collectDivergenceCandidateColumns(schema) {
183
320
  }
184
321
  return out.sort((a, b) => a.table.localeCompare(b.table) || a.column.localeCompare(b.column));
185
322
  }
323
+ /**
324
+ * The columns that could end up as a finding's {@link
325
+ * PlanDivergenceFinding.orderColumn}, so the collector reads their correlation
326
+ * too.
327
+ *
328
+ * A superset, deliberately: which one the generic plan would order by is decided
329
+ * later against the live index list, and a pg_stats read is cheap next to
330
+ * getting the wrong column's statistic. It is a SEPARATE list from the candidate
331
+ * columns because the two are filtered differently: a primary key is excluded
332
+ * from the candidates (a unique equality matches one row) and is the single most
333
+ * likely ordering column.
334
+ */
335
+ export function collectDivergenceOrderColumns(schema) {
336
+ const seen = new Set();
337
+ const out = [];
338
+ const add = (table, column) => {
339
+ const meta = schema.tables[table];
340
+ if (!meta?.allColumns.includes(column))
341
+ return;
342
+ const key = `${table}.${column}`;
343
+ if (seen.has(key))
344
+ return;
345
+ seen.add(key);
346
+ out.push({ table, column });
347
+ };
348
+ const candidateTables = new Set(collectDivergenceCandidateColumns(schema).map((c) => c.table));
349
+ for (const table of candidateTables) {
350
+ const meta = schema.tables[table];
351
+ if (!meta)
352
+ continue;
353
+ if (meta.primaryKey[0] !== undefined)
354
+ add(table, meta.primaryKey[0]);
355
+ for (const idx of meta.indexes) {
356
+ if (idx.docPath)
357
+ continue;
358
+ const lead = idx.columns[0];
359
+ if (lead !== undefined && lead !== EXPRESSION_COLUMN)
360
+ add(table, lead);
361
+ }
362
+ }
363
+ return out.sort((a, b) => a.table.localeCompare(b.table) || a.column.localeCompare(b.column));
364
+ }
186
365
  // ---------------------------------------------------------------------------
187
366
  // Live index shape
188
367
  // ---------------------------------------------------------------------------
189
- /** A plain, valid btree with no expression column and no partial predicate. */
190
- function isPlainBtree(idx) {
368
+ /** Access methods that give the planner an exact bitmap over one value's rows. */
369
+ const EQUALITY_ACCESS_METHODS = new Set(['btree', 'hash']);
370
+ /** Valid, no expression column, no partial predicate: usable for the bare predicate. */
371
+ function isPlainIndex(idx) {
191
372
  if (!idx.isValid)
192
373
  return false;
193
- if (idx.accessMethod !== undefined && idx.accessMethod !== 'btree')
194
- return false;
195
374
  if (idx.hasExpressions === true)
196
375
  return false;
197
376
  if (idx.columns.includes(EXPRESSION_COLUMN))
198
377
  return false;
199
378
  return idx.predicate == null;
200
379
  }
380
+ /** A plain, valid btree. The only shape that can also provide ORDERING. */
381
+ function isPlainBtree(idx) {
382
+ if (!isPlainIndex(idx))
383
+ return false;
384
+ return idx.accessMethod === undefined || idx.accessMethod === 'btree';
385
+ }
201
386
  /**
202
- * Whether a plain btree LEADS with `column` (so the planner has an index path
203
- * for `column = $1`), and whether the table also has a DIFFERENT ordering index
204
- * the generic plan can run away with (in practice the primary key).
387
+ * True when the index gives the planner a path for `column = $1` whose custom
388
+ * plan is a bitmap over that value's own rows: a plain btree or a plain HASH
389
+ * index leading with the column.
205
390
  *
206
- * Both halves matter. With NO index on the filter column the two plans are
207
- * identical and equally bad, which is the EXISTING missing-index finding, not
208
- * this one. With no alternative ordering index there is no other plan to flip to.
391
+ * Hash is included on measurement, not on principle. On the module-header
392
+ * fixture with a hash index on the filter column, the custom plan is a Bitmap
393
+ * Heap Scan reading 7 buffers; calling that column "unindexed" would print a
394
+ * 247-page seq scan as the good plan, which is the thing being fixed.
395
+ */
396
+ function servesEquality(idx, column) {
397
+ if (!isPlainIndex(idx))
398
+ return false;
399
+ if (idx.columns[0] !== column)
400
+ return false;
401
+ return idx.accessMethod === undefined || EQUALITY_ACCESS_METHODS.has(idx.accessMethod);
402
+ }
403
+ /**
404
+ * Whether an index serves `column = $1` (so the custom plan has a bitmap path),
405
+ * whether the table also has a DIFFERENT ordering index the generic plan can run
406
+ * away with (in practice the primary key), and whether the only thing leading
407
+ * with the column is an access method neither branch models.
408
+ *
409
+ * All three matter. With NO path at all the custom plan's alternative is a seq
410
+ * scan, which is the `unindexed-filter` branch. With no alternative ordering
411
+ * index there is no other plan to flip to. And with only a brin/gin/gist path
412
+ * the column is scored by neither rule: those scans are lossy or shaped
413
+ * differently, so both models would misdescribe the custom plan.
209
414
  */
210
415
  function indexShapeFor(snapshot, meta, column) {
211
416
  let hasIdx = false;
212
417
  let orderColumn = null;
418
+ let unmodelledAccessMethod = null;
213
419
  const pk = meta.primaryKey.length > 0 ? meta.primaryKey[0] : undefined;
214
420
  for (const idx of snapshot.indexes) {
215
421
  if (idx.table !== meta.name)
216
422
  continue;
217
- if (!isPlainBtree(idx))
218
- continue;
219
423
  const lead = idx.columns[0];
220
424
  if (lead === undefined)
221
425
  continue;
222
426
  if (lead === column) {
223
- hasIdx = true;
427
+ if (servesEquality(idx, column))
428
+ hasIdx = true;
429
+ else if (idx.isValid && idx.predicate == null && idx.hasExpressions !== true && idx.accessMethod !== undefined) {
430
+ unmodelledAccessMethod = idx.accessMethod;
431
+ }
224
432
  continue;
225
433
  }
434
+ if (!isPlainBtree(idx))
435
+ continue;
226
436
  // Prefer the primary key as the stated ordering column: it is the one a
227
437
  // paginated read almost always orders by, and it is the plan the generic
228
438
  // estimate actually chose in every measured case.
229
439
  if (orderColumn === null || lead === pk)
230
440
  orderColumn = lead;
231
441
  }
232
- return { hasIdx, orderColumn };
442
+ return { hasIdx, orderColumn, unmodelledAccessMethod: hasIdx ? null : unmodelledAccessMethod };
233
443
  }
234
444
  // ---------------------------------------------------------------------------
235
445
  // Detection
@@ -277,10 +487,17 @@ function decodeDistinct(nDistinct, rows) {
277
487
  * 3. Put the session back, so the paste does not leave `plan_cache_mode`
278
488
  * pinned for everything the user does next.
279
489
  */
280
- function buildDiagnosticSql(table, column, orderColumn, columnType) {
490
+ function buildDiagnosticSql(table, column, orderColumn, columnType, branch) {
281
491
  const t = quoteIdent(table);
282
492
  const c = quoteIdent(column);
283
493
  const o = quoteIdent(orderColumn);
494
+ // The `unindexed-filter` branch needs no different SQL: this PREPARE / six
495
+ // executions / generic_plans / force_custom / force_generic sequence is exactly
496
+ // what settled it on every fixture. Only the closing advice differs, because
497
+ // its remedy is the missing index, not a plan-cache setting.
498
+ const remedy = branch === 'unindexed-filter'
499
+ ? ['-- 3. the fix for THIS finding is the missing index above, not a plan-cache setting.']
500
+ : [];
284
501
  return [
285
502
  'SET synchronize_seqscans = off;',
286
503
  'SET max_parallel_workers_per_gather = 0;',
@@ -295,22 +512,60 @@ function buildDiagnosticSql(table, column, orderColumn, columnType) {
295
512
  'EXPLAIN (ANALYZE, BUFFERS) EXECUTE turbine_divergence(<your value>, 20);',
296
513
  'SET plan_cache_mode = force_generic_plan;',
297
514
  'EXPLAIN (ANALYZE, BUFFERS) EXECUTE turbine_divergence(<your value>, 20);',
298
- '-- 3. put the session back:',
515
+ ...remedy,
516
+ '-- put the session back:',
299
517
  'RESET plan_cache_mode; RESET synchronize_seqscans; RESET max_parallel_workers_per_gather;',
300
518
  'DEALLOCATE turbine_divergence;',
301
519
  ].join('\n');
302
520
  }
521
+ /**
522
+ * The estimated EXTRA buffer accesses the wrong plan performs, and the ONE key
523
+ * both branches are sorted on.
524
+ *
525
+ * - `sparse-value`: walkPages - min(rarestBucket, pages)
526
+ * - `unindexed-filter`: tuplesWalked - pages
527
+ *
528
+ * Both are in buffer-access units, which `approxAmplification` (the previous
529
+ * key) is not: branch B does not produce it, and a ratio ranks a 76x flip on a
530
+ * 19-page table above a 81x flip on a 2,470-page one. This DOES reorder existing
531
+ * `sparse-value` findings relative to 0.56.0.
532
+ *
533
+ * Shared units are NOT the same as equal conservatism, and the ordering should
534
+ * be read as triage rather than as a ranking of true damage. Branch A's
535
+ * `walkPages` is deliberately conservative (its own field doc says the true
536
+ * buffer count can be an order of magnitude larger), while branch B counts
537
+ * exactly that per-row access. Since `rows >> pages` on any table clearing
538
+ * branch B's tuple floor, every branch-B finding outranks every branch-A one by
539
+ * roughly the rows-per-page factor whether or not it is worse. In the human
540
+ * report most branch-B findings are attached to a missing-index finding and
541
+ * leave this list; in `--json` the ordering is visible, so it is stated here. That is a deliberate behavior change: the old key is documented as "a
542
+ * ROUGH scale only", and ordering findings by a ratio put the cheapest ones on
543
+ * top.
544
+ */
545
+ function extraBufferAccesses(f) {
546
+ if (f.branch === 'unindexed-filter')
547
+ return (f.tuplesWalked ?? 0) - f.pages;
548
+ return (f.walkPages ?? 0) - Math.min(f.rarestBucket, f.pages);
549
+ }
303
550
  /**
304
551
  * Score every candidate column against the snapshot and return the ones whose
305
552
  * distribution admits a damaging generic-plan flip.
306
553
  *
307
- * ONE rule, for the direction that could be calibrated: the generic estimate
308
- * sits ABOVE the plan boundary (so a promoted plan keeps the ordered index
309
- * scan) while the table's rarest values sit below it, and the pages that
310
- * ordered scan must walk for such a value are worth a user's attention both in
311
- * absolute terms and as a fraction of the table.
554
+ * TWO rules, split on whether a plain btree serves the filter column, because
555
+ * that is what decides what the CUSTOM plan does and therefore where the
556
+ * boundary is and what units the damage is in.
312
557
  *
313
- * `correlation` is reported but no longer gates anything. It used to route
558
+ * `sparse-value` (indexed): the generic estimate sits ABOVE the plan boundary
559
+ * (so a promoted plan keeps the ordered index scan) while the table's rarest
560
+ * values sit below it, and the pages that ordered scan must walk for such a
561
+ * value are worth a user's attention both in absolute terms and as a fraction of
562
+ * the table.
563
+ *
564
+ * `unindexed-filter` (no btree): see the block above its gates. Its boundary is
565
+ * linear in the LIMIT rather than sqrt in the pages, and it carries NO
566
+ * generic-side gate at all, for a measured reason recorded there.
567
+ *
568
+ * `correlation` is reported but gates nothing in EITHER branch. It used to route
314
569
  * clustered columns to a second rule; that rule is gone (see the module header),
315
570
  * and routing on it also made the advisor STRUCTURALLY unable to report a real
316
571
  * sparse-direction flip on any column that happened to be clustered, which was
@@ -321,6 +576,8 @@ export function findPlanDivergence(schema, snapshot) {
321
576
  const findings = [];
322
577
  const notices = [];
323
578
  let candidatesConsidered = 0;
579
+ let consideredIndexed = 0;
580
+ let consideredUnindexed = 0;
324
581
  for (const candidate of collectDivergenceCandidateColumns(schema)) {
325
582
  const meta = schema.tables[candidate.table];
326
583
  if (!meta)
@@ -334,21 +591,40 @@ export function findPlanDivergence(schema, snapshot) {
334
591
  // pg_class: either being absent is an unknown, never a zero.
335
592
  if (rows <= 0 || pages === undefined || pages <= 0)
336
593
  continue;
337
- const { hasIdx, orderColumn } = indexShapeFor(snapshot, meta, candidate.column);
338
- // No btree leading with the column: both plans are equally bad and the
339
- // existing missing-index finding already owns this case.
340
- if (!hasIdx)
594
+ const { hasIdx, orderColumn, unmodelledAccessMethod } = indexShapeFor(snapshot, meta, candidate.column);
595
+ // A brin/gin/gist path serves the equality in a way neither branch's custom
596
+ // plan describes. Scored by neither rule, but SAID so: silently dropping it
597
+ // is how the unindexed population went missing in the first place.
598
+ if (unmodelledAccessMethod !== null) {
599
+ notices.push({
600
+ table: candidate.table,
601
+ column: candidate.column,
602
+ reason: `served only by a ${unmodelledAccessMethod} index: this check models btree and hash equality paths, so neither rule describes the plan here`,
603
+ });
341
604
  continue;
605
+ }
342
606
  // No other ordering index: no alternative plan for the generic estimate to
343
- // run away with.
607
+ // run away with. The ONLY shape gate left, and it applies to both branches.
608
+ //
609
+ // `hasIdx` used to be a second one, dropped here BEFORE the considered
610
+ // counter. That put every unindexed column outside the scored population and
611
+ // outside the notices too, silently, and one of the mechanisms that produces
612
+ // real divergence lives exactly there: with no btree on the filter column
613
+ // the custom plan's alternative is a seq scan, which the generic plan will
614
+ // not choose. It now selects the branch instead of ending the candidate.
344
615
  if (orderColumn === null)
345
616
  continue;
617
+ const branch = hasIdx ? 'sparse-value' : 'unindexed-filter';
346
618
  // Counted HERE, before any scored gate. Everything above is a SHAPE
347
619
  // question (is there an index to flip between at all?); everything below is
348
620
  // a verdict on live statistics. A user must be able to tell "considered and
349
621
  // clean" from "never looked", and an earlier revision incremented this after
350
622
  // a silent table-size floor, so the two were indistinguishable.
351
623
  candidatesConsidered++;
624
+ if (hasIdx)
625
+ consideredIndexed++;
626
+ else
627
+ consideredUnindexed++;
352
628
  const dist = snapshot.columnStats?.[`${candidate.table}.${candidate.column}`];
353
629
  if (!dist) {
354
630
  notices.push({
@@ -386,8 +662,93 @@ export function findPlanDivergence(schema, snapshot) {
386
662
  // below the default statistics target), the rarest MCV IS the rarest value.
387
663
  const rarestBucket = nmcv < distinctValues ? (rows * Math.max(0, 1 - freqSum)) / (distinctValues - nmcv) : Math.min(...freqs) * rows;
388
664
  const genericEstimate = rows / distinctValues;
389
- const crossover = crossoverRows(pages, t.assumedLimit);
390
665
  const correlation = dist.correlation ?? 0;
666
+ // The ORDER column's correlation, which is the one that decides how much an
667
+ // `unindexed-filter` flip costs. Read opportunistically: a caller that did
668
+ // not ask the collector for it leaves the finding's field null rather than
669
+ // asserting a heap shape from a statistic it does not have.
670
+ const orderDist = snapshot.columnStats?.[`${candidate.table}.${orderColumn}`];
671
+ const orderColumnCorrelation = orderDist?.correlation ?? null;
672
+ // Everything common to both branches. The branch-shaped fields are filled in
673
+ // below and are left UNSET, never zero-filled, on the branch they do not
674
+ // describe.
675
+ const columnType = meta.pgTypes[candidate.column] ?? 'text';
676
+ const common = {
677
+ branch,
678
+ table: candidate.table,
679
+ column: candidate.column,
680
+ rows,
681
+ pages,
682
+ distinctValues,
683
+ genericEstimate,
684
+ rarestBucket,
685
+ densestBucket,
686
+ correlation,
687
+ orderColumnCorrelation,
688
+ assumedLimit: t.assumedLimit,
689
+ orderColumn,
690
+ columnField: meta.reverseColumnMap[candidate.column] ?? candidate.column,
691
+ orderColumnField: meta.reverseColumnMap[orderColumn] ?? orderColumn,
692
+ lastAnalyze: stats.lastAnalyze ?? null,
693
+ diagnosticSql: buildDiagnosticSql(candidate.table, candidate.column, orderColumn, columnType, branch),
694
+ thresholds: t,
695
+ };
696
+ if (branch === 'unindexed-filter') {
697
+ // BRANCH B. Two gates, and deliberately no third.
698
+ //
699
+ // GATE 1, rarestBucket < assumedLimit. The measured boundary at which the
700
+ // CUSTOM planner abandons the seq scan for the ordered walk is k x limit,
701
+ // with k = cost(full ordered index scan) / cost(seq scan + sort), measured
702
+ // 3.32 on a 267-page table and 3.245 on a 2,667-page one: the SAME
703
+ // boundary at 10x the size, so it is size independent, and it is linear in
704
+ // the limit. Gating at 1 x limit instead of the measured 3.3 x keeps this
705
+ // strictly inside the boundary with better than 2x margin, and it is also
706
+ // the regime of maximum damage: below the limit the ordered walk can never
707
+ // fill the LIMIT, so it walks the entire table.
708
+ //
709
+ // The recall this gives up is real and it GROWS with the table, in the
710
+ // same buffer-access units this module sorts on. Measured on the
711
+ // module-header fixture at 200,000 rows / 2,470 pages with a rarest bucket
712
+ // of 60: custom Seq Scan 2,473 buffers / 8.1 ms, generic PK Index Scan
713
+ // 200,547 buffers / 99.0 ms. That is an 81x flip and 198,074 extra buffer
714
+ // accesses, declined here because 60 >= 20, and it is an order of
715
+ // magnitude more absolute damage than the largest finding this branch DOES
716
+ // report on the 20,000-row fixture (19,824 extra accesses). The honest way
717
+ // to take it back is to derive k from pages, rows and the cost constants,
718
+ // not to widen the constant; until that lands it is a stated recall limit,
719
+ // not a safe one.
720
+ //
721
+ // GATE 2, an absolute floor on tuples walked. See minGenericTupleWalk.
722
+ //
723
+ // NO GENERIC-SIDE GATE, and that is the measured part rather than an
724
+ // omission. Turbine always parameterizes LIMIT on Postgres, and an unknown
725
+ // `LIMIT $n` is estimated as 10% of the child node's row estimate, so the
726
+ // generic plan's ordered index scan is discounted 10x WHATEVER the child
727
+ // estimate is. It therefore takes the ordered walk whenever
728
+ // cost(full ordered scan) < 10 x cost(seq scan + sort), i.e. whenever
729
+ // k < 10, and k measured 3.2 to 3.3 on every fixture, independent of
730
+ // n_distinct. Demonstrated directly: a 20,000-row / 267-page table with
731
+ // n_distinct 5,001 (generic estimate 4 rows, far below any crossover)
732
+ // still gave custom = Seq Scan 267 buffers and generic = PK Index Scan
733
+ // 19,994 buffers, a real 74x divergence that a `genericEstimate >=
734
+ // crossover` gate silently rejects. The generic plan's choice here is a
735
+ // property of the SHAPE, not of the distribution.
736
+ if (rarestBucket >= t.assumedLimit)
737
+ continue;
738
+ // Always exactly `rows` under gate 1; see the field's own note.
739
+ const tuplesWalked = rarestBucket > 0 ? Math.min(rows, (rows * t.assumedLimit) / rarestBucket) : rows;
740
+ if (tuplesWalked < t.minGenericTupleWalk)
741
+ continue;
742
+ findings.push({
743
+ ...common,
744
+ tuplesWalked,
745
+ worstCaseAmplification: tuplesWalked / pages,
746
+ heapNearlyOrdered: orderColumnCorrelation !== null && orderColumnCorrelation >= t.nearExactOrderCorrelation,
747
+ });
748
+ continue;
749
+ }
750
+ // BRANCH A, unchanged from 0.56.0.
751
+ const crossover = crossoverRows(pages, t.assumedLimit);
391
752
  // Pages the ordered index scan the generic plan keeps must walk before it
392
753
  // accumulates `assumedLimit` matches of the rarest value: limit / matching
393
754
  // of the table, and the whole table once the value has fewer rows than the
@@ -405,34 +766,18 @@ export function findPlanDivergence(schema, snapshot) {
405
766
  if (nmcv < distinctValues && rarestBucket < crossover) {
406
767
  valuesBelowCrossover += Math.round(distinctValues - nmcv);
407
768
  }
408
- const columnType = meta.pgTypes[candidate.column] ?? 'text';
409
769
  findings.push({
410
- table: candidate.table,
411
- column: candidate.column,
412
- rows,
413
- pages,
414
- distinctValues,
415
- genericEstimate,
416
- rarestBucket,
417
- densestBucket,
418
- correlation,
770
+ ...common,
419
771
  crossoverRows: crossover,
420
772
  crossoverRowsWide: crossoverRows(pages, t.wideLimit),
421
- assumedLimit: t.assumedLimit,
422
773
  valuesBelowCrossover,
423
774
  walkPages,
424
775
  walkFraction,
425
776
  approxAmplification,
426
- orderColumn,
427
- columnField: meta.reverseColumnMap[candidate.column] ?? candidate.column,
428
- orderColumnField: meta.reverseColumnMap[orderColumn] ?? orderColumn,
429
- lastAnalyze: stats.lastAnalyze ?? null,
430
- diagnosticSql: buildDiagnosticSql(candidate.table, candidate.column, orderColumn, columnType),
431
- thresholds: t,
432
777
  });
433
778
  }
434
- findings.sort((a, b) => b.approxAmplification - a.approxAmplification ||
779
+ findings.sort((a, b) => extraBufferAccesses(b) - extraBufferAccesses(a) ||
435
780
  a.table.localeCompare(b.table) ||
436
781
  a.column.localeCompare(b.column));
437
- return { findings, notices, candidatesConsidered };
782
+ return { findings, notices, candidatesConsidered, consideredIndexed, consideredUnindexed };
438
783
  }