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