turbine-orm 0.55.0 → 0.56.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.
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Plan-divergence advisor, the third question doctor asks about a probe column.
3
+ *
4
+ * `index-advisor.ts` answers "which relation probes have no index" from pure
5
+ * topology. `index-stats.ts` answers "is adding that index worth it" from live
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".
9
+ *
10
+ * THE MECHANISM. Postgres may promote a named prepared statement to a GENERIC
11
+ * plan from its sixth execution onward, and ONLY when the generic plan's
12
+ * estimated cost is not worse than the average custom cost. A generic plan
13
+ * cannot see any parameter value, so it substitutes a default for each one:
14
+ *
15
+ * - an unknown equality `col = $1` is estimated as reltuples / n_distinct;
16
+ * - an unknown `LIMIT $n` is estimated as 10% of the CHILD node's row estimate.
17
+ *
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).
25
+ *
26
+ * WHAT THIS DELIBERATELY DOES NOT MODEL, because it was tried and it did not
27
+ * work. An earlier revision carried a second rule for the opposite direction (a
28
+ * physically clustered column whose densest value is far ABOVE the generic
29
+ * estimate). Measured against live fixtures it was wrong more often than right,
30
+ * and twice it was wrong with the SIGN INVERTED: it predicted "at least 16,032x"
31
+ * and "at least 2,675x" on columns where the generic plan was in fact 10x and
32
+ * 105x BETTER than the custom one, so acting on the finding would have made
33
+ * those reads dramatically slower. The reason is structural, not a bad constant:
34
+ * whether that flip helps or hurts turns on WHERE in the heap the value's rows
35
+ * physically sit, and no pg_stats input carries that. `correlation` is a
36
+ * whole-column property and is identical whether the dominant band is at the
37
+ * head of the heap or its tail. The rule was removed rather than retuned.
38
+ *
39
+ * The same blind spot bounds what remains: this check can see how MANY rows a
40
+ * value has, not WHERE they are, so it under-reports (a rare value packed at the
41
+ * end of the heap is worse than modelled) and it cannot see the third-party
42
+ * shapes where a generic plan is the better one. A clean report is not evidence
43
+ * of immunity.
44
+ *
45
+ * EXPOSURE IS NOT AN INCIDENT. A finding says the DISTRIBUTION admits a
46
+ * damaging flip. It does NOT say the backend is choosing the bad plan today:
47
+ * `auto` promotes only when the generic plan's ESTIMATED cost is not worse than
48
+ * the average custom cost, and on a shape whose generic plan is estimated
49
+ * expensive it never promotes at all. That is why every finding ships a
50
+ * diagnostic that checks `pg_prepared_statements.generic_plans` BEFORE it
51
+ * compares the two plans: the counter is the only thing that establishes real
52
+ * exposure.
53
+ *
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.
62
+ *
63
+ * FRESHNESS GATE. The cost-tier half of doctor gates on `stats_reset` age,
64
+ * because it normalizes WRITE COUNTERS by it. Nothing here reads a counter: every
65
+ * input comes from pg_stats, which is refreshed by ANALYZE. This check therefore
66
+ * gates on ANALYZE freshness instead (a column with no pg_stats row, or an
67
+ * n_distinct of 0, is suppressed with a notice, and every finding carries the
68
+ * table's last_analyze). Reusing the counter gate would have silenced the check
69
+ * on every cluster whose `pg_stat_database.stats_reset` is NULL, which is the
70
+ * default state and has nothing to do with whether ANALYZE has ever run.
71
+ *
72
+ * Postgres-only (pg_stats + plan_cache_mode). Pure: no pg import, no EXPLAIN.
73
+ */
74
+ import { type StatsSnapshot } from './index-stats.js';
75
+ import type { SchemaMetadata } from './schema.js';
76
+ /**
77
+ * Gates behind a plan-divergence finding. Exported and printed with every
78
+ * finding so the reasoning is never a black box.
79
+ *
80
+ * Each one is either derived from the cost model and then checked against
81
+ * measurement, or fitted to a measured flip boundary; the comments record which.
82
+ */
83
+ export declare const PLAN_DIVERGENCE_THRESHOLDS: {
84
+ /**
85
+ * The LIMIT the advisor assumes, because it cannot see the application's.
86
+ *
87
+ * It is NOT a conservative bound in either direction, and an earlier comment
88
+ * here claimed it was. The crossover grows as sqrt(limit), so raising the
89
+ * limit moves BOTH gates: `rarestBucket < crossover` gets easier to satisfy
90
+ * and `genericEstimate >= crossover` gets harder, and the second one can turn
91
+ * a finding off. 20 is simply a common first page. Every finding also reports
92
+ * the crossover at {@link wideLimit} so a caller paginating in thousands can
93
+ * see where their own limit lands.
94
+ */
95
+ readonly assumedLimit: 20;
96
+ /** A second crossover reported alongside, for callers paginating in thousands. */
97
+ readonly wideLimit: 1000;
98
+ /**
99
+ * How many of the table's pages the WRONG plan must walk before this is worth
100
+ * a user's attention: ~400 KB of heap. Below it the flip cannot cost more than
101
+ * a millisecond or two however bad the ratio looks.
102
+ *
103
+ * This replaced a `relpages >= 1000` table-size floor, which was wrong and
104
+ * measurably so: a wrong plan on a SMALL table is not cheap, because an ordered
105
+ * index scan's cost is driven by how much of the table it walks, not by how big
106
+ * the table is. On a 285-page / 18,500-row fixture the generic plan read 336
107
+ * buffers against the custom plan's 7 (48x), and the old floor dropped that
108
+ * column without even counting it as considered.
109
+ */
110
+ readonly minWalkPages: 50;
111
+ /**
112
+ * ...and it must walk at least this FRACTION of the table. The two gates are
113
+ * different questions (absolute cost, and how badly the plan is mismatched to
114
+ * the value), and a finding needs both.
115
+ */
116
+ readonly minWalkFraction: 0.1;
117
+ };
118
+ export type PlanDivergenceThresholds = typeof PLAN_DIVERGENCE_THRESHOLDS;
119
+ export interface PlanDivergenceFinding {
120
+ table: string;
121
+ column: string;
122
+ /** pg_class.reltuples. */
123
+ rows: number;
124
+ /** pg_class.relpages, the size input the crossover is computed from. */
125
+ pages: number;
126
+ /** n_distinct, decoded the way Postgres decodes it (negative = fraction of rows). */
127
+ distinctValues: number;
128
+ /** rows / distinctValues: what a generic plan assumes `col = $1` matches. */
129
+ genericEstimate: number;
130
+ /** Estimated rowcount of the rarest value (MCV minimum, or the residual bucket). */
131
+ rarestBucket: number;
132
+ /** Estimated rowcount of the densest value (MCV maximum). Reported, not gated on. */
133
+ densestBucket: number;
134
+ /** pg_stats.correlation, signed as reported. Reported, not gated on. */
135
+ correlation: number;
136
+ /** sqrt(assumedLimit x pages): below this many true rows, bitmap+sort wins. */
137
+ crossoverRows: number;
138
+ /** The same crossover at {@link PLAN_DIVERGENCE_THRESHOLDS.wideLimit}. */
139
+ crossoverRowsWide: number;
140
+ assumedLimit: number;
141
+ /** How many distinct values sit on the wrong side of the crossover. */
142
+ valuesBelowCrossover: number;
143
+ /** Pages the generic plan's ordered scan walks for the rarest value. */
144
+ walkPages: number;
145
+ /** {@link walkPages} as a fraction of the table. */
146
+ walkFraction: number;
147
+ /**
148
+ * walkPages / the pages the custom plan's bitmap scan reads, as a ROUGH scale
149
+ * only. It is an estimate from statistics, not a bound in either direction:
150
+ * it assumes the rare value's rows are spread uniformly (they usually are not,
151
+ * which makes the real number larger) and that the bitmap scan touches a
152
+ * separate page per row (which makes it smaller). On every fixture measured
153
+ * while calibrating this, the true amplification came out LARGER than the
154
+ * estimate, never smaller, but that is three fixtures and not a guarantee.
155
+ * The EXPLAIN pair shipped with the finding is what settles it.
156
+ */
157
+ approxAmplification: number;
158
+ /** The other indexed column the generic plan can order by (usually the PK). */
159
+ orderColumn: string;
160
+ /**
161
+ * `column` / `orderColumn` as the TypeScript FIELD names the Turbine API takes.
162
+ * The SQL in a finding names database columns; the code suggestion next to it
163
+ * must name fields, or a user pastes a snake_case key into a camelCase `where`.
164
+ */
165
+ columnField: string;
166
+ orderColumnField: string;
167
+ /**
168
+ * When the table's column statistics were last refreshed (the later of
169
+ * last_analyze / last_autoanalyze). Null when never analyzed or unreadable.
170
+ * Everything above describes the distribution AS OF this moment; a table that
171
+ * has since changed shape can make the finding wrong in either direction.
172
+ */
173
+ lastAnalyze: Date | null;
174
+ /** A copy-pasteable check that CONFIRMS or refutes the finding. */
175
+ diagnosticSql: string;
176
+ thresholds: PlanDivergenceThresholds;
177
+ }
178
+ /** A candidate suppressed because its statistics could not support a verdict. */
179
+ export interface PlanDivergenceNotice {
180
+ table: string;
181
+ column: string;
182
+ reason: string;
183
+ }
184
+ export interface PlanDivergenceReport {
185
+ findings: PlanDivergenceFinding[];
186
+ notices: PlanDivergenceNotice[];
187
+ /** How many (table, column) candidates were examined against live statistics. */
188
+ candidatesConsidered: number;
189
+ }
190
+ /** A (table, column) pair whose distribution statistics the collector should read. */
191
+ export interface DivergenceCandidate {
192
+ table: string;
193
+ column: string;
194
+ }
195
+ /**
196
+ * Every column whose value distribution is worth reading: a column Turbine
197
+ * probes by equality (a relation FK) or that the schema already indexes as a
198
+ * leading key, minus the columns that are unique on their own (a unique
199
+ * equality matches one row, so both plans agree).
200
+ *
201
+ * Purely schema-derived, so the collector knows what to read BEFORE any stats
202
+ * exist. Whether the column is really served by a btree is decided later,
203
+ * against the live index list.
204
+ */
205
+ export declare function collectDivergenceCandidateColumns(schema: SchemaMetadata): DivergenceCandidate[];
206
+ /**
207
+ * The plan-boundary crossover: below this many truly matching rows, running
208
+ * `ORDER BY <other column> LIMIT n` as bitmap-scan + sort is cheaper than
209
+ * walking the ordering index and filtering.
210
+ *
211
+ * Derivation: an ordered index scan expects to read about (limit / matching) x
212
+ * pages before it accumulates `limit` matches; a bitmap scan reads about
213
+ * min(matching, pages). Those are equal at matching = sqrt(limit x pages).
214
+ * Measured flip points tracked this within 13 to 26% across two orders of
215
+ * magnitude of limit, which is why the size input is relpages rather than a
216
+ * row count.
217
+ *
218
+ * PREMISE, and it is the same blind spot the whole check has: both halves
219
+ * assume the matching rows are SCATTERED through the heap. On a clustered
220
+ * column a bitmap scan reads far fewer than min(matching, pages), so there is
221
+ * no flip at any limit and this crossover does not describe the table at all.
222
+ * pg_stats.correlation hints at it but does not settle it (a column can be
223
+ * uncorrelated overall and still have one value packed in the tail, which is
224
+ * exactly the counterexample fixture). Treat a crossover as a reason to run
225
+ * the diagnostic block, never as a measurement.
226
+ */
227
+ export declare function crossoverRows(pages: number, limit: number): number;
228
+ /**
229
+ * Score every candidate column against the snapshot and return the ones whose
230
+ * distribution admits a damaging generic-plan flip.
231
+ *
232
+ * ONE rule, for the direction that could be calibrated: the generic estimate
233
+ * sits ABOVE the plan boundary (so a promoted plan keeps the ordered index
234
+ * scan) while the table's rarest values sit below it, and the pages that
235
+ * ordered scan must walk for such a value are worth a user's attention both in
236
+ * absolute terms and as a fraction of the table.
237
+ *
238
+ * `correlation` is reported but no longer gates anything. It used to route
239
+ * clustered columns to a second rule; that rule is gone (see the module header),
240
+ * and routing on it also made the advisor STRUCTURALLY unable to report a real
241
+ * sparse-direction flip on any column that happened to be clustered, which was
242
+ * measured at 1,165x on one fixture.
243
+ */
244
+ export declare function findPlanDivergence(schema: SchemaMetadata, snapshot: StatsSnapshot): PlanDivergenceReport;