turbine-orm 0.56.0 → 0.58.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 +284 -38
- package/dist/cjs/client.js +3 -39
- package/dist/cjs/plan-divergence.d.ts +320 -38
- package/dist/cjs/plan-divergence.js +413 -67
- package/dist/cjs/plan-flip-probe.d.ts +165 -0
- package/dist/cjs/plan-flip-probe.js +304 -0
- 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 +285 -39
- package/dist/client.js +4 -40
- package/dist/plan-divergence.d.ts +320 -38
- package/dist/plan-divergence.js +412 -67
- package/dist/plan-flip-probe.d.ts +165 -0
- package/dist/plan-flip-probe.js +262 -0
- 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
|
@@ -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
|
|
8
|
-
*
|
|
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
|
|
19
|
-
* `WHERE col = $1 ORDER BY <other indexed column> LIMIT $n`, where the
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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.
|
|
55
|
-
* PLANNER costs the boundary in PAGES (an ordered
|
|
56
|
-
* limit / matching of the table's pages before it fills
|
|
57
|
-
* {@link crossoverRows} is derived from relpages and
|
|
58
|
-
* track it. The DAMAGE
|
|
59
|
-
* conservatively; when the ordering index is not correlated with
|
|
60
|
-
* each row examined is a separate block access and the true buffer
|
|
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
|
|
@@ -103,20 +178,82 @@ export declare const PLAN_DIVERGENCE_THRESHOLDS: {
|
|
|
103
178
|
* This replaced a `relpages >= 1000` table-size floor, which was wrong and
|
|
104
179
|
* measurably so: a wrong plan on a SMALL table is not cheap, because an ordered
|
|
105
180
|
* index scan's cost is driven by how much of the table it walks, not by how big
|
|
106
|
-
* the table is.
|
|
107
|
-
*
|
|
108
|
-
*
|
|
181
|
+
* the table is. Fixture, stated in full so the number is checkable:
|
|
182
|
+
*
|
|
183
|
+
* CREATE TABLE t (id int PRIMARY KEY, organization_id int NOT NULL, payload text NOT NULL);
|
|
184
|
+
* -- 18,500 rows, repeat('p', 85) payload => 285 relpages, buckets
|
|
185
|
+
* -- 9,000 / 5,000 / 4,498 / 2, CREATE INDEX ON t (organization_id).
|
|
186
|
+
* -- read: WHERE organization_id = $1 ORDER BY id LIMIT $2, rarest value, limit 20.
|
|
187
|
+
*
|
|
188
|
+
* Inserted in `id` order the generic plan read 337 buffers against the custom
|
|
189
|
+
* plan's 6 (56x); inserted in hash order, the same shape read 18,574 against 7.
|
|
190
|
+
* The old floor dropped that column without even counting it as considered.
|
|
109
191
|
*/
|
|
110
192
|
readonly minWalkPages: 50;
|
|
111
193
|
/**
|
|
112
194
|
* ...and it must walk at least this FRACTION of the table. The two gates are
|
|
113
195
|
* different questions (absolute cost, and how badly the plan is mismatched to
|
|
114
196
|
* the value), and a finding needs both.
|
|
197
|
+
*
|
|
198
|
+
* `sparse-value` only. In the `unindexed-filter` branch the walk is the whole
|
|
199
|
+
* table by construction (gate 1 there puts the rarest bucket below the LIMIT,
|
|
200
|
+
* so the ordered scan can never fill it), which makes this gate vacuous.
|
|
201
|
+
* Reusing it there would read as a gate that did work.
|
|
115
202
|
*/
|
|
116
203
|
readonly minWalkFraction: 0.1;
|
|
204
|
+
/**
|
|
205
|
+
* `unindexed-filter` only: how many TUPLES the promoted plan must walk before
|
|
206
|
+
* this is worth a user's attention.
|
|
207
|
+
*
|
|
208
|
+
* Amplification in that branch is rows-per-page and nothing else, so it is
|
|
209
|
+
* size independent and only an absolute floor separates a real finding from
|
|
210
|
+
* noise. Measured on the fixture in the module header, warm cache, rarest
|
|
211
|
+
* bucket 2, limit 20 (rows / relpages / custom buffers / custom ms / generic
|
|
212
|
+
* buffers / generic ms):
|
|
213
|
+
*
|
|
214
|
+
* 1,500 / 19 / 22 / 0.14 / 1,506 / 0.28
|
|
215
|
+
* 5,000 / 62 / 65 / 0.17 / 5,020 / 0.64
|
|
216
|
+
* 10,000 / 124 / 127 / 0.29 / 10,038 / 2.20
|
|
217
|
+
* 20,000 / 247 / 250 / 0.83 / 20,074 / 5.84
|
|
218
|
+
* 200,000 / 2,470 / 2,473 / 6.06 / 200,587 / 63.07
|
|
219
|
+
*
|
|
220
|
+
* (An earlier revision of this table carried a page column about 8% high, from
|
|
221
|
+
* a wider row than any fixture in this repo. The numbers above come from the
|
|
222
|
+
* DDL printed in the module header and nothing else.)
|
|
223
|
+
*
|
|
224
|
+
* 10,000 tuples is where the warm-cache delta reaches the "a millisecond or
|
|
225
|
+
* two" line {@link minWalkPages} is calibrated to, and where the cold-cache
|
|
226
|
+
* exposure (10,000 candidate random page accesses) stops being trivial. Not
|
|
227
|
+
* gated on pages instead: at 10,000 rows any realistic row width already
|
|
228
|
+
* clears 50 pages, and a narrow table with 10,000 rows in 40 pages is still a
|
|
229
|
+
* 250x flip.
|
|
230
|
+
*/
|
|
231
|
+
readonly minGenericTupleWalk: 10000;
|
|
232
|
+
/**
|
|
233
|
+
* `unindexed-filter` only, and it QUALIFIES a finding rather than gating it:
|
|
234
|
+
* at or above this correlation between the heap and {@link
|
|
235
|
+
* PlanDivergenceFinding.orderColumn}, the flip is real but reads ~1x, so the
|
|
236
|
+
* finding is printed with that stated instead of with a rows-per-page ratio
|
|
237
|
+
* it would not reach.
|
|
238
|
+
*
|
|
239
|
+
* Fitted to the measured ladder on {@link
|
|
240
|
+
* PlanDivergenceFinding.orderColumnCorrelation}: 0.99998 (one page of local
|
|
241
|
+
* disorder) reads 3.1x and 0.99993 (two pages) reads 41x, so the boundary is
|
|
242
|
+
* between them. That is the fifth decimal place of a SAMPLED statistic, which
|
|
243
|
+
* is exactly why nothing is suppressed on it.
|
|
244
|
+
*/
|
|
245
|
+
readonly nearExactOrderCorrelation: 0.99995;
|
|
117
246
|
};
|
|
118
247
|
export type PlanDivergenceThresholds = typeof PLAN_DIVERGENCE_THRESHOLDS;
|
|
248
|
+
/**
|
|
249
|
+
* Which scoring rule produced a finding. They are NOT interchangeable: the
|
|
250
|
+
* boundary, the damage units, the remedy and the rendered text all differ, and
|
|
251
|
+
* the branch-shaped fields below are populated per branch.
|
|
252
|
+
*/
|
|
253
|
+
export type PlanDivergenceBranch = 'sparse-value' | 'unindexed-filter';
|
|
119
254
|
export interface PlanDivergenceFinding {
|
|
255
|
+
/** Which rule scored this column. See {@link PlanDivergenceBranch}. */
|
|
256
|
+
branch: PlanDivergenceBranch;
|
|
120
257
|
table: string;
|
|
121
258
|
column: string;
|
|
122
259
|
/** pg_class.reltuples. */
|
|
@@ -131,19 +268,111 @@ export interface PlanDivergenceFinding {
|
|
|
131
268
|
rarestBucket: number;
|
|
132
269
|
/** Estimated rowcount of the densest value (MCV maximum). Reported, not gated on. */
|
|
133
270
|
densestBucket: number;
|
|
134
|
-
/**
|
|
271
|
+
/**
|
|
272
|
+
* pg_stats.correlation OF THE FILTER COLUMN, signed as reported. Reported,
|
|
273
|
+
* never gated on.
|
|
274
|
+
*
|
|
275
|
+
* It is NOT the statistic that decides how much the `unindexed-filter` flip
|
|
276
|
+
* costs. That one is {@link orderColumnCorrelation}, and an earlier revision
|
|
277
|
+
* printed THIS number next to a sentence about the ORDER column's physical
|
|
278
|
+
* order, which is a different column and, on the fixtures below, a different
|
|
279
|
+
* number by three orders of magnitude.
|
|
280
|
+
*/
|
|
135
281
|
correlation: number;
|
|
136
|
-
/**
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
282
|
+
/**
|
|
283
|
+
* pg_stats.correlation OF {@link orderColumn}: how closely the heap's physical
|
|
284
|
+
* order tracks the column the generic plan walks. Null when the column has no
|
|
285
|
+
* pg_stats row (it is read opportunistically, and a missing one is an unknown,
|
|
286
|
+
* never a zero).
|
|
287
|
+
*
|
|
288
|
+
* This is what decides the SIZE of an `unindexed-filter` flip, because the
|
|
289
|
+
* generic plan's cost is one heap fetch per index entry: when consecutive
|
|
290
|
+
* entries land on the same already-pinned page the fetch is free, and when
|
|
291
|
+
* they do not it is a buffer access. Measured on six otherwise identical
|
|
292
|
+
* 20,000-row / 247-page fixtures (the module-header DDL), varying ONLY the
|
|
293
|
+
* INSERT ordering, custom plan 250 buffers in every one:
|
|
294
|
+
*
|
|
295
|
+
* 1.00000 exact id order generic 303 buffers, 1.2x
|
|
296
|
+
* 0.99998 shuffled within ~1 page generic 783 buffers, 3.1x
|
|
297
|
+
* 0.99993 shuffled within ~2 pages generic 10,303 buffers, 41x
|
|
298
|
+
* 0.99974 shuffled within ~4 pages generic 15,148 buffers, 61x
|
|
299
|
+
* 0.99372 shuffled within ~20 pages generic 19,046 buffers, 76x
|
|
300
|
+
* -0.00065 hash order generic 20,074 buffers, 80x
|
|
301
|
+
*
|
|
302
|
+
* (An earlier revision of this table claimed 25x for the one-page row. It does
|
|
303
|
+
* not reproduce: the transition is between a one-page and a two-page window,
|
|
304
|
+
* because an index scan holds its heap pin, so disorder WITHIN a page costs
|
|
305
|
+
* nothing.)
|
|
306
|
+
*
|
|
307
|
+
* The plan flips in all six; only the magnitude differs. The boundary sits
|
|
308
|
+
* between two adjacent sampled values, so this is used to QUALIFY a finding
|
|
309
|
+
* ({@link heapNearlyOrdered}), never to suppress one: gating here would be
|
|
310
|
+
* fitted to the fifth decimal place of a sampled statistic and would drop
|
|
311
|
+
* genuine 41x to 80x findings.
|
|
312
|
+
*/
|
|
313
|
+
orderColumnCorrelation?: number | null;
|
|
314
|
+
/**
|
|
315
|
+
* True when {@link orderColumnCorrelation} is at or above
|
|
316
|
+
* {@link PLAN_DIVERGENCE_THRESHOLDS.nearExactOrderCorrelation}, i.e. the heap
|
|
317
|
+
* is in near-exact {@link orderColumn} order and the measured amplification is
|
|
318
|
+
* ~1x rather than the rows-per-page figure in
|
|
319
|
+
* {@link worstCaseAmplification}.
|
|
320
|
+
*
|
|
321
|
+
* The known false positive of the `unindexed-filter` branch, disclosed as a
|
|
322
|
+
* field so a `--json` consumer sees it without re-deriving the rule. It is a
|
|
323
|
+
* hint to measure, not a verdict: it is one sampled statistic away from the
|
|
324
|
+
* 41x row of the ladder above, in both directions.
|
|
325
|
+
*/
|
|
326
|
+
heapNearlyOrdered?: boolean;
|
|
327
|
+
/**
|
|
328
|
+
* sqrt(assumedLimit x pages): below this many true rows, bitmap+sort wins.
|
|
329
|
+
*
|
|
330
|
+
* `sparse-value` ONLY, and absent on the other branch rather than zero. The
|
|
331
|
+
* derivation compares an ordered index scan against a BITMAP scan; with no
|
|
332
|
+
* index on the filter column there is no bitmap path, and the real boundary is
|
|
333
|
+
* linear in the limit and independent of pages (see the branch-B header).
|
|
334
|
+
* Emitting a sqrt number there would be a measurement-shaped lie.
|
|
335
|
+
*/
|
|
336
|
+
crossoverRows?: number;
|
|
337
|
+
/** The same crossover at {@link PLAN_DIVERGENCE_THRESHOLDS.wideLimit}. `sparse-value` only. */
|
|
338
|
+
crossoverRowsWide?: number;
|
|
140
339
|
assumedLimit: number;
|
|
141
|
-
/** How many distinct values sit on the wrong side of the crossover. */
|
|
142
|
-
valuesBelowCrossover
|
|
143
|
-
/** Pages the generic plan's ordered scan walks for the rarest value. */
|
|
144
|
-
walkPages
|
|
145
|
-
/** {@link walkPages} as a fraction of the table. */
|
|
146
|
-
walkFraction
|
|
340
|
+
/** How many distinct values sit on the wrong side of the crossover. `sparse-value` only. */
|
|
341
|
+
valuesBelowCrossover?: number;
|
|
342
|
+
/** Pages the generic plan's ordered scan walks for the rarest value. `sparse-value` only. */
|
|
343
|
+
walkPages?: number;
|
|
344
|
+
/** {@link walkPages} as a fraction of the table. `sparse-value` only. */
|
|
345
|
+
walkFraction?: number;
|
|
346
|
+
/**
|
|
347
|
+
* `unindexed-filter` ONLY: how many tuples the promoted ordered scan fetches
|
|
348
|
+
* before it fills the LIMIT, `min(rows, rows x limit / rarestBucket)`.
|
|
349
|
+
*
|
|
350
|
+
* On this branch it is ALWAYS exactly `rows`, and that is arithmetic rather
|
|
351
|
+
* than a coincidence: gate 1 puts `rarestBucket` below the limit, so
|
|
352
|
+
* `rows x limit / rarestBucket > rows` and the `min` always takes `rows`. The
|
|
353
|
+
* general formula is kept because gate 1 is the thing most likely to become
|
|
354
|
+
* size-aware (see its comment), and it was checked against measurement on one
|
|
355
|
+
* table at three bucket sizes: bucket 2 predicted 20,000 measured 19,992;
|
|
356
|
+
* bucket 20 predicted 20,000 measured 19,854; bucket 60 predicted 6,667
|
|
357
|
+
* measured 6,774. Two of those three bucket sizes are outside what gate 1
|
|
358
|
+
* currently admits, and the formula also assumes the rare value is spread
|
|
359
|
+
* uniformly in ORDER-column space: on a fixture with the rare rows contiguous
|
|
360
|
+
* at the tail of `id` order it predicted 66,667 and measured 200,547.
|
|
361
|
+
*/
|
|
362
|
+
tuplesWalked?: number;
|
|
363
|
+
/**
|
|
364
|
+
* `unindexed-filter` ONLY: {@link tuplesWalked} / pages, i.e. the generic
|
|
365
|
+
* plan's buffer accesses against the seq scan's. Given the collapse above it
|
|
366
|
+
* is exactly the table's rows-per-page, which is a table property: it says how
|
|
367
|
+
* bad the flip is IF it happens, and nothing about how likely this column is
|
|
368
|
+
* to be the one that flips.
|
|
369
|
+
*
|
|
370
|
+
* An ESTIMATE, not a bound, in both directions. It assumes each index-order
|
|
371
|
+
* heap fetch is a separate buffer access, which is true when the heap is not
|
|
372
|
+
* ordered by {@link orderColumn} and false when it is: see
|
|
373
|
+
* {@link heapNearlyOrdered}, where the measured reading is ~1x instead.
|
|
374
|
+
*/
|
|
375
|
+
worstCaseAmplification?: number;
|
|
147
376
|
/**
|
|
148
377
|
* walkPages / the pages the custom plan's bitmap scan reads, as a ROUGH scale
|
|
149
378
|
* only. It is an estimate from statistics, not a bound in either direction:
|
|
@@ -153,8 +382,10 @@ export interface PlanDivergenceFinding {
|
|
|
153
382
|
* while calibrating this, the true amplification came out LARGER than the
|
|
154
383
|
* estimate, never smaller, but that is three fixtures and not a guarantee.
|
|
155
384
|
* The EXPLAIN pair shipped with the finding is what settles it.
|
|
385
|
+
*
|
|
386
|
+
* `sparse-value` only.
|
|
156
387
|
*/
|
|
157
|
-
approxAmplification
|
|
388
|
+
approxAmplification?: number;
|
|
158
389
|
/** The other indexed column the generic plan can order by (usually the PK). */
|
|
159
390
|
orderColumn: string;
|
|
160
391
|
/**
|
|
@@ -186,6 +417,28 @@ export interface PlanDivergenceReport {
|
|
|
186
417
|
notices: PlanDivergenceNotice[];
|
|
187
418
|
/** How many (table, column) candidates were examined against live statistics. */
|
|
188
419
|
candidatesConsidered: number;
|
|
420
|
+
/**
|
|
421
|
+
* The same count split by whether the filter column has a plain btree. Reported
|
|
422
|
+
* separately because "N columns were scored" used to be true only of indexed
|
|
423
|
+
* ones, and the unindexed population was silently outside it.
|
|
424
|
+
*/
|
|
425
|
+
consideredIndexed: number;
|
|
426
|
+
consideredUnindexed: number;
|
|
427
|
+
/**
|
|
428
|
+
* True when every `unindexed-filter` finding was put to the planner via
|
|
429
|
+
* `plan-flip-probe.ts` rather than reported on statistics alone.
|
|
430
|
+
*
|
|
431
|
+
* Absent or false means the findings below are UNVERIFIED: the probe was
|
|
432
|
+
* skipped, the engine is not Postgres, or the connection refused it.
|
|
433
|
+
*/
|
|
434
|
+
flipProbed?: boolean;
|
|
435
|
+
/**
|
|
436
|
+
* How many candidate findings the planner refuted, i.e. columns where the
|
|
437
|
+
* generic plan keeps the same sequential scan the good plan uses so there is
|
|
438
|
+
* no divergence to reach. Reported because a check that silently discards two
|
|
439
|
+
* thirds of what it found should say so.
|
|
440
|
+
*/
|
|
441
|
+
flipRefuted?: number;
|
|
189
442
|
}
|
|
190
443
|
/** A (table, column) pair whose distribution statistics the collector should read. */
|
|
191
444
|
export interface DivergenceCandidate {
|
|
@@ -201,8 +454,29 @@ export interface DivergenceCandidate {
|
|
|
201
454
|
* Purely schema-derived, so the collector knows what to read BEFORE any stats
|
|
202
455
|
* exist. Whether the column is really served by a btree is decided later,
|
|
203
456
|
* against the live index list.
|
|
457
|
+
*
|
|
458
|
+
* NOTE, because it is the obvious next idea and it is a no-op: feeding
|
|
459
|
+
* `findMissingRelationIndexes`' recommended columns in here adds nothing. Both
|
|
460
|
+
* derive from the same relation topology, this walker already enumerates every
|
|
461
|
+
* single-column relation probe whether or not it is indexed, and the advisor's
|
|
462
|
+
* only extra members are COMPOSITE probes, which this check deliberately does
|
|
463
|
+
* not model. The unindexed population was never missing from the candidate set;
|
|
464
|
+
* it was dropped later, by a shape gate. That gate is where it was fixed.
|
|
204
465
|
*/
|
|
205
466
|
export declare function collectDivergenceCandidateColumns(schema: SchemaMetadata): DivergenceCandidate[];
|
|
467
|
+
/**
|
|
468
|
+
* The columns that could end up as a finding's {@link
|
|
469
|
+
* PlanDivergenceFinding.orderColumn}, so the collector reads their correlation
|
|
470
|
+
* too.
|
|
471
|
+
*
|
|
472
|
+
* A superset, deliberately: which one the generic plan would order by is decided
|
|
473
|
+
* later against the live index list, and a pg_stats read is cheap next to
|
|
474
|
+
* getting the wrong column's statistic. It is a SEPARATE list from the candidate
|
|
475
|
+
* columns because the two are filtered differently: a primary key is excluded
|
|
476
|
+
* from the candidates (a unique equality matches one row) and is the single most
|
|
477
|
+
* likely ordering column.
|
|
478
|
+
*/
|
|
479
|
+
export declare function collectDivergenceOrderColumns(schema: SchemaMetadata): DivergenceCandidate[];
|
|
206
480
|
/**
|
|
207
481
|
* The plan-boundary crossover: below this many truly matching rows, running
|
|
208
482
|
* `ORDER BY <other column> LIMIT n` as bitmap-scan + sort is cheaper than
|
|
@@ -229,13 +503,21 @@ export declare function crossoverRows(pages: number, limit: number): number;
|
|
|
229
503
|
* Score every candidate column against the snapshot and return the ones whose
|
|
230
504
|
* distribution admits a damaging generic-plan flip.
|
|
231
505
|
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
506
|
+
* TWO rules, split on whether a plain btree serves the filter column, because
|
|
507
|
+
* that is what decides what the CUSTOM plan does and therefore where the
|
|
508
|
+
* boundary is and what units the damage is in.
|
|
509
|
+
*
|
|
510
|
+
* `sparse-value` (indexed): the generic estimate sits ABOVE the plan boundary
|
|
511
|
+
* (so a promoted plan keeps the ordered index scan) while the table's rarest
|
|
512
|
+
* values sit below it, and the pages that ordered scan must walk for such a
|
|
513
|
+
* value are worth a user's attention both in absolute terms and as a fraction of
|
|
514
|
+
* the table.
|
|
515
|
+
*
|
|
516
|
+
* `unindexed-filter` (no btree): see the block above its gates. Its boundary is
|
|
517
|
+
* linear in the LIMIT rather than sqrt in the pages, and it carries NO
|
|
518
|
+
* generic-side gate at all, for a measured reason recorded there.
|
|
237
519
|
*
|
|
238
|
-
* `correlation` is reported but
|
|
520
|
+
* `correlation` is reported but gates nothing in EITHER branch. It used to route
|
|
239
521
|
* clustered columns to a second rule; that rule is gone (see the module header),
|
|
240
522
|
* and routing on it also made the advisor STRUCTURALLY unable to report a real
|
|
241
523
|
* sparse-direction flip on any column that happened to be clustered, which was
|