@quereus/store 4.11.0 → 4.12.1
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 +131 -10
- package/dist/src/common/cached-kv-store.d.ts +16 -0
- package/dist/src/common/cached-kv-store.d.ts.map +1 -1
- package/dist/src/common/cached-kv-store.js +43 -0
- package/dist/src/common/cached-kv-store.js.map +1 -1
- package/dist/src/common/index.d.ts +1 -0
- package/dist/src/common/index.d.ts.map +1 -1
- package/dist/src/common/index.js +2 -0
- package/dist/src/common/index.js.map +1 -1
- package/dist/src/common/key-builder.d.ts +9 -6
- package/dist/src/common/key-builder.d.ts.map +1 -1
- package/dist/src/common/key-builder.js +17 -6
- package/dist/src/common/key-builder.js.map +1 -1
- package/dist/src/common/kv-store.d.ts +89 -3
- package/dist/src/common/kv-store.d.ts.map +1 -1
- package/dist/src/common/kv-store.js +21 -1
- package/dist/src/common/kv-store.js.map +1 -1
- package/dist/src/common/memory-store.d.ts +1 -0
- package/dist/src/common/memory-store.d.ts.map +1 -1
- package/dist/src/common/memory-store.js +7 -0
- package/dist/src/common/memory-store.js.map +1 -1
- package/dist/src/common/pk-key-resolution.d.ts +17 -4
- package/dist/src/common/pk-key-resolution.d.ts.map +1 -1
- package/dist/src/common/pk-key-resolution.js +23 -8
- package/dist/src/common/pk-key-resolution.js.map +1 -1
- package/dist/src/common/store-module-access-plan.d.ts +1 -1
- package/dist/src/common/store-module-access-plan.d.ts.map +1 -1
- package/dist/src/common/store-module-access-plan.js +434 -105
- package/dist/src/common/store-module-access-plan.js.map +1 -1
- package/dist/src/common/store-module-alter-column.d.ts.map +1 -1
- package/dist/src/common/store-module-alter-column.js +25 -2
- package/dist/src/common/store-module-alter-column.js.map +1 -1
- package/dist/src/common/store-module-alter.d.ts.map +1 -1
- package/dist/src/common/store-module-alter.js +36 -13
- package/dist/src/common/store-module-alter.js.map +1 -1
- package/dist/src/common/store-module-catalog.d.ts +6 -4
- package/dist/src/common/store-module-catalog.d.ts.map +1 -1
- package/dist/src/common/store-module-catalog.js +8 -6
- package/dist/src/common/store-module-catalog.js.map +1 -1
- package/dist/src/common/store-module-index.d.ts +98 -7
- package/dist/src/common/store-module-index.d.ts.map +1 -1
- package/dist/src/common/store-module-index.js +236 -81
- package/dist/src/common/store-module-index.js.map +1 -1
- package/dist/src/common/store-module.d.ts.map +1 -1
- package/dist/src/common/store-module.js +7 -3
- package/dist/src/common/store-module.js.map +1 -1
- package/dist/src/common/store-table-base.d.ts +30 -0
- package/dist/src/common/store-table-base.d.ts.map +1 -1
- package/dist/src/common/store-table-base.js +71 -12
- package/dist/src/common/store-table-base.js.map +1 -1
- package/dist/src/common/store-table-constraints.js +3 -3
- package/dist/src/common/store-table-constraints.js.map +1 -1
- package/dist/src/common/store-table-scan.d.ts +208 -98
- package/dist/src/common/store-table-scan.d.ts.map +1 -1
- package/dist/src/common/store-table-scan.js +353 -105
- package/dist/src/common/store-table-scan.js.map +1 -1
- package/dist/src/testing/index.d.ts +17 -5
- package/dist/src/testing/index.d.ts.map +1 -1
- package/dist/src/testing/index.js +16 -5
- package/dist/src/testing/index.js.map +1 -1
- package/dist/src/testing/kv-conformance.d.ts +49 -0
- package/dist/src/testing/kv-conformance.d.ts.map +1 -1
- package/dist/src/testing/kv-conformance.js +125 -0
- package/dist/src/testing/kv-conformance.js.map +1 -1
- package/dist/src/testing/kv-lifecycle.d.ts +22 -0
- package/dist/src/testing/kv-lifecycle.d.ts.map +1 -0
- package/dist/src/testing/kv-lifecycle.js +12 -0
- package/dist/src/testing/kv-lifecycle.js.map +1 -0
- package/dist/src/testing/kv-naming-conformance.d.ts +77 -0
- package/dist/src/testing/kv-naming-conformance.d.ts.map +1 -0
- package/dist/src/testing/kv-naming-conformance.js +208 -0
- package/dist/src/testing/kv-naming-conformance.js.map +1 -0
- package/dist/src/testing/kv-reclaim-conformance.d.ts +52 -0
- package/dist/src/testing/kv-reclaim-conformance.d.ts.map +1 -0
- package/dist/src/testing/kv-reclaim-conformance.js +287 -0
- package/dist/src/testing/kv-reclaim-conformance.js.map +1 -0
- package/package.json +5 -5
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* advertise, and the scan layer there executes it. A plan that claims a filter the scan
|
|
9
9
|
* cannot honor drops the residual Filter and returns wrong rows, so every soundness
|
|
10
10
|
* predicate the two share lives in `pk-key-resolution.ts` (`keyOrderMatchesCollation`,
|
|
11
|
-
* `
|
|
11
|
+
* `indexRangeAtPositionIsOrderSafe`, `pkOrderPreservingPrefixLength`) rather than being
|
|
12
12
|
* restated here. The declines that are NOT shared — partial indexes, the multi-seek cap,
|
|
13
13
|
* and the semantic-ordering column ban on multi-seeks specifically — are stated in both
|
|
14
14
|
* files, and changing one means changing the other.
|
|
@@ -17,21 +17,21 @@
|
|
|
17
17
|
* no module state beyond the table's configured key collation, which the caller passes in.
|
|
18
18
|
*/
|
|
19
19
|
import { AccessPlanBuilder, equalitySeekKeyCount, hasSemanticOrdering, isMultiValueEquality } from '@quereus/quereus';
|
|
20
|
-
import {
|
|
20
|
+
import { indexPrefixSeekIsCollationExact, indexRangeAtPositionIsOrderSafe, pkOrderPreservingPrefixLength, resolveIndexKeyCollations, resolvePkKeyCollations, } from './pk-key-resolution.js';
|
|
21
21
|
/**
|
|
22
22
|
* Planner-side constraint operator groups, as `BestAccessPlanRequest.filters` spells
|
|
23
23
|
* them. Kept as one source of truth for the access-plan code below: {@link computeBestAccessPlan}
|
|
24
24
|
* classifies each pushed filter with these, and {@link tryIndexAccessPlan} claims a filter as
|
|
25
25
|
* handled only when it falls in the group the engine's access-path rule will consume.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
26
|
+
*
|
|
27
|
+
* An IN is N equalities served as one multi-seek (`plan=5`), whether its members are a
|
|
28
|
+
* literal list or a runtime-valued set — so EVERY equality arm here, primary key and
|
|
29
|
+
* secondary index alike, fills its equality roles from this group. (The primary-key arms
|
|
30
|
+
* used to keep a `'='`-only group, on the reasoning that a `_primary_` multi-seek's
|
|
31
|
+
* emission order would break the isolation layer's primary-key merge. Both halves of that
|
|
32
|
+
* are now false: the merge bug was fixed as `bug-isolation-multiseek-merge-order`, and
|
|
33
|
+
* `StoreTableScan.scanMultiSeekPrimary` emits ascending by encoded data key — which IS
|
|
34
|
+
* primary-key order — specifically to satisfy it.)
|
|
35
35
|
*/
|
|
36
36
|
const EQ_OR_IN_OPS = ['=', 'IN'];
|
|
37
37
|
const LOWER_BOUND_OPS = ['>', '>='];
|
|
@@ -47,6 +47,69 @@ const RANGE_OPS = [...LOWER_BOUND_OPS, ...UPPER_BOUND_OPS];
|
|
|
47
47
|
const MAX_MULTI_SEEK_KEYS = 1000;
|
|
48
48
|
/** Cost of positioning one index seek, in the same unit as the per-row fetch cost. */
|
|
49
49
|
const INDEX_SEEK_COST = 0.5;
|
|
50
|
+
/**
|
|
51
|
+
* Cost of resolving ONE secondary-index entry to its row, in the same unit as the full
|
|
52
|
+
* scan's 1.0-per-row sequential read.
|
|
53
|
+
*
|
|
54
|
+
* A secondary-index path reads TWO stores per matched row — the index entry, then the row
|
|
55
|
+
* it names — where a sequential scan reads one row per row. `AccessPlanBuilder.eqMatch` /
|
|
56
|
+
* `.rangeScan` charge only the first (0.3 / 0.5 per row), so the `eq`, `prefixRange` and
|
|
57
|
+
* `range` seek arms add this term on top of the shape's own cost. The MULTI-SEEK arm does
|
|
58
|
+
* not; the measured reason is at that arm in {@link tryIndexAccessPlan}.
|
|
59
|
+
*
|
|
60
|
+
* Why 1.0: `StoreTableScan` resolves entries in `ROW_RESOLUTION_BATCH`-sized `getMany`
|
|
61
|
+
* batches (`store-table-scan.ts`: one store round trip per 256 entries, not per row), so a resolved row
|
|
62
|
+
* costs about what a sequentially-iterated row costs — the index path simply pays it ON TOP
|
|
63
|
+
* OF the entry read. Nothing here has been measured against real storage; it is a shape
|
|
64
|
+
* constant like {@link ARM_SELECTIVITY}, chosen for parity rather than from a benchmark.
|
|
65
|
+
*
|
|
66
|
+
* The constant is not freely tunable upward. Because {@link ARM_SELECTIVITY} is a fixed
|
|
67
|
+
* FRACTION of the table, every arm's cost is linear in `estimatedRows` and so is the full
|
|
68
|
+
* scan's — an arm either always prices above a sequential scan or never does, for a given
|
|
69
|
+
* constant. The switch-over points: the `range` arm (0.3 × N × (0.5 + R) > N) flips at
|
|
70
|
+
* R > 2.83, `prefixRange` at R > 6.17, `eq` at R > 9.7. So raising this past ~2.8 does not
|
|
71
|
+
* make range seeks "more expensive", it DISABLES them outright. Real per-column statistics
|
|
72
|
+
* (see the NOTE on {@link ARM_SELECTIVITY}) are what would make the comparison
|
|
73
|
+
* discriminate per query rather than per arm.
|
|
74
|
+
*/
|
|
75
|
+
const ROW_RESOLUTION_COST = 1.0;
|
|
76
|
+
/**
|
|
77
|
+
* Estimated rows each arm returns, as a fraction of the request's `estimatedRows`. The
|
|
78
|
+
* store keeps no per-column histograms, so these are shape constants, not statistics.
|
|
79
|
+
*
|
|
80
|
+
* `prefixRange` sits between the two pre-existing factors, as its window does: it is
|
|
81
|
+
* narrower than a bare leading-column range (the prefix pins every column ahead of the
|
|
82
|
+
* bound) and wider than the pure-equality estimate, which stands for a prefix pinning
|
|
83
|
+
* every column the predicate names.
|
|
84
|
+
*
|
|
85
|
+
* NOTE: these are the store's biggest cost-model gap, and no per-row cost term closes it.
|
|
86
|
+
* They are shape constants, so `where col = ?` is modelled at 10% of the table whatever
|
|
87
|
+
* the predicate actually matches, and `request.estimatedRows` is the table's row count,
|
|
88
|
+
* not a selectivity-adjusted estimate. A predicate matching MOST of the table therefore
|
|
89
|
+
* still prices as an index seek returning 10% of it, and the full-scan comparison in
|
|
90
|
+
* {@link computeBestAccessPlan} cannot rescue it — the fix is real per-column statistics,
|
|
91
|
+
* not another constant here. Two things have to land for that: the store has to KEEP a
|
|
92
|
+
* value distribution (`StoreTableBase.getStatistics` reports the row count only — a
|
|
93
|
+
* distinct count or histogram would cost a scan, which is why `ANALYZE` collects the
|
|
94
|
+
* per-column half itself), and `BestAccessPlanRequest` has to CARRY it, which it does not
|
|
95
|
+
* today — the module sees `estimatedRows` and the filters, nothing else, so even an
|
|
96
|
+
* analyzed table plans identically. Until then every cost decision this file makes is
|
|
97
|
+
* per-ARM, never per-PREDICATE.
|
|
98
|
+
*
|
|
99
|
+
* NOTE: the arms are exclusive per index, but two INDEXES compete on cost — and an index
|
|
100
|
+
* on the equality prefix ALONE prices its `eq` arm cheaper (0.1) than the composite index
|
|
101
|
+
* prices its `prefixRange` arm (0.15), so a schema carrying both `(a)` and `(a, b)` picks
|
|
102
|
+
* `(a)` for `a = ? and b > ?` and leaves the `b` bound residual. Answers are unaffected.
|
|
103
|
+
* Fine while redundant prefix indexes are rare; if one shows up as a slow plan, either
|
|
104
|
+
* scale the `eq` factor by how many index columns the prefix actually pins, or drop
|
|
105
|
+
* `prefixRange` below `eq` — do not just swap the two constants, which would then mis-rank
|
|
106
|
+
* a genuine leading-column range.
|
|
107
|
+
*/
|
|
108
|
+
const ARM_SELECTIVITY = {
|
|
109
|
+
eq: 0.1,
|
|
110
|
+
prefixRange: 0.15,
|
|
111
|
+
range: 0.3,
|
|
112
|
+
};
|
|
50
113
|
/**
|
|
51
114
|
* Build `handledFilters` by claiming ONLY the constraints `rule-select-access-path`
|
|
52
115
|
* actually consumes: per seek column the FIRST '=' (equality seek), or the FIRST lower
|
|
@@ -82,12 +145,65 @@ function rangeRoles(colIdx) {
|
|
|
82
145
|
return [{ colIdx, ops: LOWER_BOUND_OPS }, { colIdx, ops: UPPER_BOUND_OPS }];
|
|
83
146
|
}
|
|
84
147
|
/**
|
|
85
|
-
* The one equality role each seek column of an equality/prefix seek fills
|
|
86
|
-
*
|
|
87
|
-
* (
|
|
148
|
+
* The one equality role each seek column of an equality/prefix seek fills — filled by a
|
|
149
|
+
* `'='` or a well-formed `IN` alike, on the primary-key arms and the secondary-index arm
|
|
150
|
+
* identically (see the note on {@link EQ_OR_IN_OPS}).
|
|
88
151
|
*/
|
|
89
|
-
function equalityRoles(colIdxs
|
|
90
|
-
return colIdxs.map(colIdx => ({ colIdx, ops }));
|
|
152
|
+
function equalityRoles(colIdxs) {
|
|
153
|
+
return colIdxs.map(colIdx => ({ colIdx, ops: EQ_OR_IN_OPS }));
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The longest LEADING run of `colIdxs` whose every column carries a filter that can fill
|
|
157
|
+
* an equality role, and what that run costs to seek.
|
|
158
|
+
*
|
|
159
|
+
* Per column the pin is the FIRST such filter ({@link equalitySeekKeyCount} — a `'='`, a
|
|
160
|
+
* non-empty literal `IN`, or a runtime-valued `IN` set). That is the same positional pick
|
|
161
|
+
* {@link claimFirstPerRole} claims and `rule-select-access-path`'s `eqBySeekCol` seeks on,
|
|
162
|
+
* so this module's claim and the rule's pick cannot disagree. A runtime-valued set
|
|
163
|
+
* contributes its `maxCount` ceiling — the worst case the engine may deliver — so every
|
|
164
|
+
* gate downstream judges the largest multi-seek it could ever be asked to perform.
|
|
165
|
+
*
|
|
166
|
+
* ONE helper for both the primary-key arms ({@link resolvePrimaryKeyPins}, which needs the
|
|
167
|
+
* run to cover the whole key) and the secondary-index arm (which takes whatever prefix it
|
|
168
|
+
* gets): separate copies of this loop are how a `'='`-only PK arm and an IN-claiming index
|
|
169
|
+
* arm drifted apart in the first place — see the note on {@link EQ_OR_IN_OPS}.
|
|
170
|
+
*
|
|
171
|
+
* `isMultiSeek` is NOT `seekKeyCount > 1`: a runtime-valued set is delivered as a `plan=5`
|
|
172
|
+
* multi-seek even at `maxCount === 1`, so it must be judged as one rather than as the plain
|
|
173
|
+
* point read its ceiling arithmetic would otherwise suggest. (`seekKeyCount > 1` implies
|
|
174
|
+
* the flag — every factor above 1 is a multi-value equality — so the flag only ever *adds*
|
|
175
|
+
* the `maxCount === 1` runtime-set case.)
|
|
176
|
+
*/
|
|
177
|
+
function resolveEqualityPins(filters, colIdxs) {
|
|
178
|
+
const cols = [];
|
|
179
|
+
let seekKeyCount = 1;
|
|
180
|
+
let isMultiSeek = false;
|
|
181
|
+
for (const colIdx of colIdxs) {
|
|
182
|
+
const pin = filters.find(f => f.columnIndex === colIdx && equalitySeekKeyCount(f) !== null);
|
|
183
|
+
if (!pin)
|
|
184
|
+
break;
|
|
185
|
+
cols.push(colIdx);
|
|
186
|
+
seekKeyCount *= equalitySeekKeyCount(pin);
|
|
187
|
+
if (isMultiValueEquality(pin))
|
|
188
|
+
isMultiSeek = true;
|
|
189
|
+
}
|
|
190
|
+
return { cols, seekKeyCount, isMultiSeek };
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The equality pins covering the WHOLE primary key, or null when a member is unpinned.
|
|
194
|
+
*
|
|
195
|
+
* A per-column pin rather than a count of pinned columns: `a = 1 and a = 2` on a composite
|
|
196
|
+
* PK `(a, b)` pins `a`, finds nothing for `b`, and correctly reports the key unpinned.
|
|
197
|
+
* Counting raw equality filters instead would read that predicate as "both PK columns
|
|
198
|
+
* pinned", claim both filters handled, then — with no complete PK equality set to seek —
|
|
199
|
+
* degrade to a sequential scan whose residual has already been discarded, returning the
|
|
200
|
+
* whole table.
|
|
201
|
+
*/
|
|
202
|
+
function resolvePrimaryKeyPins(filters, pkColumns) {
|
|
203
|
+
if (pkColumns.length === 0)
|
|
204
|
+
return null;
|
|
205
|
+
const pins = resolveEqualityPins(filters, pkColumns);
|
|
206
|
+
return pins.cols.length === pkColumns.length ? pins : null;
|
|
91
207
|
}
|
|
92
208
|
/**
|
|
93
209
|
* The access plan this module advertises for `request`, before the caller stamps the
|
|
@@ -102,36 +218,80 @@ function equalityRoles(colIdxs, ops) {
|
|
|
102
218
|
*/
|
|
103
219
|
export function computeBestAccessPlan(db, tableInfo, request, tableKeyCollation) {
|
|
104
220
|
const estimatedRows = request.estimatedRows ?? 1000;
|
|
105
|
-
// Check for primary key equality constraints. Count DISTINCT pinned PK columns:
|
|
106
|
-
// counting raw '=' filters would read `a = 1 and a = 2` on a composite PK (a, b)
|
|
107
|
-
// as "both PK columns pinned", claim both filters handled, then — with no
|
|
108
|
-
// complete PK equality set to seek — degrade to a sequential scan whose residual
|
|
109
|
-
// has already been discarded, returning the whole table.
|
|
110
221
|
const pkColumns = tableInfo.primaryKeyDefinition.map(pk => pk.index);
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
222
|
+
// Hoisted ABOVE both primary-key arms: the multi-seek arm's ordering advertisement
|
|
223
|
+
// needs it, and it is cheap (a short loop over the PK members). Its other consumer is
|
|
224
|
+
// the leading-PK range arm below, which declines when the leading PK column's key
|
|
225
|
+
// bytes do not order the way its comparator does (`pkOrderPreservingPrefix === 0`):
|
|
226
|
+
// `StoreTable.analyzePKAccess` declines the byte window under exactly that condition,
|
|
227
|
+
// so claiming the range filters handled would drop the residual Filter and return the
|
|
228
|
+
// whole table.
|
|
229
|
+
const pkOrderPreservingPrefix = pkOrderPreservingPrefixLength(db, tableInfo, resolvePkKeyCollations(tableInfo.primaryKeyDefinition, tableInfo.columns, tableKeyCollation), tableKeyCollation);
|
|
230
|
+
// Primary-key equality arms. Both live INSIDE this branch — the multi-seek is tried
|
|
231
|
+
// BEFORE the leading-PK range arm below, which is what keeps a full-primary-key IN
|
|
232
|
+
// list from being shadowed by a range on the leading column (the general
|
|
233
|
+
// arm-competition problem is backlog `bug-store-pk-range-preempts-cheaper-index`; do
|
|
234
|
+
// not fix it here).
|
|
235
|
+
//
|
|
236
|
+
// **No collation gate, and that is not an oversight.** The secondary-index arm gates
|
|
237
|
+
// its equality window on `indexPrefixSeekIsCollationExact`; the PK arms need no
|
|
238
|
+
// equivalent, for two independent reasons:
|
|
239
|
+
// - `reconcilePkCollations` (store-module-schema-rewrite.ts) rewrites an undecorated
|
|
240
|
+
// text PK column's declared collation to the table key collation K at CREATE time,
|
|
241
|
+
// so for every PK member the key collation, the declared collation, and the
|
|
242
|
+
// collation `matchesFilters` re-compares under are the same name. The divergent
|
|
243
|
+
// shape the secondary arm declines — a collation-blind `json`/temporal column under
|
|
244
|
+
// an index column carrying an explicit non-BINARY `COLLATE` — cannot occur on a PK:
|
|
245
|
+
// column DDL type-gates that `COLLATE` out, and those types are declined by the
|
|
246
|
+
// semantic-ordering gate below anyway.
|
|
247
|
+
// - `StoreTableScan.scanMultiSeekPrimary` re-applies `matchesFilters` per resolved
|
|
248
|
+
// row, so even a hypothetical coarser window over-fetches and is trimmed rather
|
|
249
|
+
// than under-fetching.
|
|
250
|
+
// The multi-seek is exactly the point arm run N times, so its collation exposure is
|
|
251
|
+
// the point arm's, unchanged.
|
|
252
|
+
//
|
|
253
|
+
// The one divergence neither reason covers is PREDICATE-side and invisible from here:
|
|
254
|
+
// `where pk collate nocase in (…)` over a BINARY key column would seek exact-case
|
|
255
|
+
// windows and UNDER-fetch. `PredicateConstraint` carries no collation, so this module
|
|
256
|
+
// cannot see it and always claims; `rule-select-access-path`'s `classifyCollationCover`
|
|
257
|
+
// (which reads the predicate's effective collation off the source expression) is what
|
|
258
|
+
// declines that shape to a scan + residual. A new PK arm inherits that protection only
|
|
259
|
+
// by going through the rule's index-aware path — one more reason `setSeekColumns` on
|
|
260
|
+
// the point arm below is load-bearing.
|
|
261
|
+
const pkPins = resolvePrimaryKeyPins(request.filters, pkColumns);
|
|
262
|
+
if (pkPins && !pkPins.isMultiSeek) {
|
|
263
|
+
// Full PK match - point lookup (single row; no monotonic advertisement).
|
|
264
|
+
//
|
|
265
|
+
// `setSeekColumns` is load-bearing now that this arm also claims a single-element
|
|
266
|
+
// `IN`: it routes the plan through `rule-select-access-path`'s INDEX-AWARE arm,
|
|
267
|
+
// whose `eqBySeekCol` accepts a one-element IN as an equality seek key. The legacy
|
|
268
|
+
// PK arm it would otherwise take matches `op === '='` only, so the claimed IN would
|
|
269
|
+
// be seeked nowhere and come back as a reattached residual over a full scan — the
|
|
270
|
+
// right rows at the wrong cost. For a plain `'='` the two arms build an identical
|
|
271
|
+
// `_primary_` `plan=2` seek under an identical collation-cover lookup.
|
|
116
272
|
return AccessPlanBuilder
|
|
117
273
|
.eqMatch(1, 0.1)
|
|
118
|
-
.setHandledFilters(claimFirstPerRole(request.filters, equalityRoles(pkColumns
|
|
274
|
+
.setHandledFilters(claimFirstPerRole(request.filters, equalityRoles(pkColumns)))
|
|
119
275
|
.setIsSet(true)
|
|
120
276
|
.setIndexName('_primary_')
|
|
277
|
+
.setSeekColumns(pkColumns)
|
|
121
278
|
.setExplanation('Store primary key lookup')
|
|
122
279
|
.build();
|
|
123
280
|
}
|
|
281
|
+
if (pkPins) {
|
|
282
|
+
const plan = primaryKeyMultiSeekPlan(tableInfo, request, pkColumns, pkPins, estimatedRows, pkOrderPreservingPrefix);
|
|
283
|
+
// A gate decline FALLS THROUGH to the arms below rather than returning cost-only.
|
|
284
|
+
// That is safe: an `IN` is not in {@link RANGE_OPS}, so the leading-PK-range arm
|
|
285
|
+
// cannot grab it, and it lands on the secondary-index arms / full scan exactly as
|
|
286
|
+
// it did before this arm existed.
|
|
287
|
+
if (plan)
|
|
288
|
+
return plan;
|
|
289
|
+
}
|
|
124
290
|
// Check for range constraints on the leading PK column.
|
|
125
291
|
// The legacy access-path rule (rule-select-access-path.ts) only forwards
|
|
126
292
|
// range bounds for primaryKeyDefinition[0]; ranges on later PK columns
|
|
127
293
|
// are silently dropped if marked handled. So only claim handled=true
|
|
128
294
|
// when the range is on the first PK column.
|
|
129
|
-
//
|
|
130
|
-
// The seek is also declined when the leading PK column's key bytes do not order the
|
|
131
|
-
// way its comparator does (`pkOrderPreservingPrefix === 0`): `StoreTable.analyzePKAccess`
|
|
132
|
-
// declines the byte window under exactly that condition, so claiming the range filters
|
|
133
|
-
// handled here would drop the residual Filter and return the whole table.
|
|
134
|
-
const pkOrderPreservingPrefix = pkOrderPreservingPrefixLength(db, tableInfo, resolvePkKeyCollations(tableInfo.primaryKeyDefinition, tableInfo.columns, tableKeyCollation), tableKeyCollation);
|
|
135
295
|
const firstPkColumn = tableInfo.primaryKeyDefinition[0]?.index;
|
|
136
296
|
const hasLeadingPkRange = firstPkColumn !== undefined
|
|
137
297
|
&& pkOrderPreservingPrefix >= 1
|
|
@@ -153,36 +313,87 @@ export function computeBestAccessPlan(db, tableInfo, request, tableKeyCollation)
|
|
|
153
313
|
return { ...plan, ...buildPkOrderingAdvertisement(tableInfo, request, pkOrderPreservingPrefix) };
|
|
154
314
|
}
|
|
155
315
|
// Check for secondary index usage. `StoreTable.query` now implements the
|
|
156
|
-
// secondary-index scan arm (leading-prefix EQ point / leading-column range
|
|
316
|
+
// secondary-index scan arm (leading-prefix EQ point / leading-column range /
|
|
317
|
+
// prefix-equality + trailing-column range),
|
|
157
318
|
// so we advertise the index with `indexName` + `seekColumns` and mark the
|
|
158
319
|
// covered filters handled — subject to the range arm's order-safety gate in
|
|
159
320
|
// {@link tryIndexAccessPlan}. A cost-only plan (no seek) is kept as a fallback
|
|
160
321
|
// when no index yields a sound seek, preserving the prior "cheaper cost, filters
|
|
161
322
|
// unhandled, residual retained" behavior.
|
|
323
|
+
//
|
|
324
|
+
// The sequential scan this module would otherwise fall back to, built up-front so the
|
|
325
|
+
// seek arms can be PRICED AGAINST its cost below: `rule-select-access-path` takes
|
|
326
|
+
// whatever single plan this function returns and never compares it with an alternative,
|
|
327
|
+
// so a seek that costs more than reading the table start to finish is only rejected if
|
|
328
|
+
// the module rejects it here. Built once and re-explained on the way out rather than
|
|
329
|
+
// re-deriving `AccessPlanBuilder.fullScan`'s cost formula for the comparison — the
|
|
330
|
+
// drift {@link AccessPlanBuilder.addCost} exists to prevent.
|
|
331
|
+
const scanPlan = {
|
|
332
|
+
...AccessPlanBuilder
|
|
333
|
+
.fullScan(estimatedRows)
|
|
334
|
+
.setHandledFilters(new Array(request.filters.length).fill(false))
|
|
335
|
+
.setExplanation('Store full table scan')
|
|
336
|
+
.build(),
|
|
337
|
+
...buildPkOrderingAdvertisement(tableInfo, request, pkOrderPreservingPrefix),
|
|
338
|
+
};
|
|
162
339
|
const indexes = tableInfo.indexes || [];
|
|
163
340
|
let bestSeekPlan = null;
|
|
164
341
|
let costOnlyFallback = null;
|
|
165
342
|
for (const index of indexes) {
|
|
166
343
|
if (index.columns.length === 0)
|
|
167
344
|
continue;
|
|
168
|
-
const
|
|
169
|
-
if (!
|
|
345
|
+
const candidate = tryIndexAccessPlan(db, tableInfo, request, index, estimatedRows);
|
|
346
|
+
if (!candidate)
|
|
170
347
|
continue;
|
|
348
|
+
const { plan } = candidate;
|
|
171
349
|
// A fully-handled seek (indexName + seekColumns set) is a candidate: keep the
|
|
172
350
|
// cheapest one seen so far rather than the first, so declaration order of the
|
|
173
351
|
// indexes doesn't decide the plan. Strict '<' so ties keep the first candidate,
|
|
174
352
|
// matching MemoryTableModule.findBestAccessPlan's `indexPlan.cost < bestPlan.cost`.
|
|
175
353
|
if (plan.seekColumnIndexes && plan.seekColumnIndexes.length > 0) {
|
|
176
|
-
if (!bestSeekPlan || plan.cost < bestSeekPlan.cost)
|
|
177
|
-
bestSeekPlan =
|
|
354
|
+
if (!bestSeekPlan || plan.cost < bestSeekPlan.plan.cost)
|
|
355
|
+
bestSeekPlan = candidate;
|
|
178
356
|
continue;
|
|
179
357
|
}
|
|
180
358
|
// Otherwise remember the first cost-only advertisement as a fallback.
|
|
181
359
|
if (!costOnlyFallback)
|
|
182
360
|
costOnlyFallback = plan;
|
|
183
361
|
}
|
|
184
|
-
if (bestSeekPlan)
|
|
185
|
-
|
|
362
|
+
if (bestSeekPlan) {
|
|
363
|
+
//
|
|
364
|
+
// The MULTI-SEEK arm is exempt, and that is a measured decision rather than an
|
|
365
|
+
// oversight. `rule-key-set-seek` (engine side) does not consume this arm as a PLAN —
|
|
366
|
+
// it probes `getBestAccessPlan` with SYNTHESIZED runtime-set filters at 2 and 1000
|
|
367
|
+
// keys, reads the two costs as a straight line, and interpolates the key count at
|
|
368
|
+
// which the seek would overtake a scan. A probe answer that stops naming an index is
|
|
369
|
+
// read as "the module declined" and the whole rewrite is abandoned. Since nothing
|
|
370
|
+
// here distinguishes a probe from a user's own `col in (…)`, substituting this
|
|
371
|
+
// module's own scan verdict at 1000 keys silently switches key-set semi joins off:
|
|
372
|
+
// MEASURED, it fails 11 tests in `key-set-seek-store.spec.ts` on tables of 200 and
|
|
373
|
+
// 300 rows where the seek is unambiguously the right plan at the key counts actually
|
|
374
|
+
// used. The arm's brakes stay `inCount × INDEX_SEEK_COST` (which is what the engine
|
|
375
|
+
// interpolates) and {@link MAX_MULTI_SEEK_KEYS}; the engine makes the scan comparison
|
|
376
|
+
// this exemption skips, off the same numbers.
|
|
377
|
+
//
|
|
378
|
+
// Ties keep the seek: it returns fewer rows for the rest of the plan to carry.
|
|
379
|
+
if (bestSeekPlan.isMultiSeek || bestSeekPlan.plan.cost <= scanPlan.cost)
|
|
380
|
+
return bestSeekPlan.plan;
|
|
381
|
+
// Losing the seek is SAFE, never a wrong answer: the scan claims no filters, so the
|
|
382
|
+
// engine keeps every one of them as a residual Filter and the row set is identical.
|
|
383
|
+
// Deliberately returns the scan rather than falling through to `costOnlyFallback`
|
|
384
|
+
// below — a cost-only plan performs this same sequential scan while advertising an
|
|
385
|
+
// index arm's (cheaper) cost, so falling through would undo the comparison.
|
|
386
|
+
//
|
|
387
|
+
// NOTE: a vetoed seek is indistinguishable from a DECLINE to the engine, and
|
|
388
|
+
// `rule-key-set-seek` reads a probe answer that names no index as "the module
|
|
389
|
+
// declined" and abandons its rewrite. Harmless while the veto is reachable only
|
|
390
|
+
// where an arm's estimate is the whole table (a one-row table, per the arithmetic on
|
|
391
|
+
// {@link ROW_RESOLUTION_COST}) — a key-set seek there is worthless anyway. If real
|
|
392
|
+
// per-column statistics ever make the veto fire on tables worth seeking, the
|
|
393
|
+
// comparison has to exempt any request carrying a runtime-valued set, not just the
|
|
394
|
+
// arm this module happens to pick for it.
|
|
395
|
+
return { ...scanPlan, explains: 'Store full table scan (cheaper than the best index seek)' };
|
|
396
|
+
}
|
|
186
397
|
// NOTE: a cost-only plan carries no PK-order advertisement even though the store still
|
|
187
398
|
// iterates in PK key order for it (`StoreTable.query` full-scans), so `... where v > 'x'
|
|
188
399
|
// order by <pk>` picks up a Sort it did not need. Still true, just rarer than it was: an
|
|
@@ -200,14 +411,65 @@ export function computeBestAccessPlan(db, tableInfo, request, tableKeyCollation)
|
|
|
200
411
|
return costOnlyFallback;
|
|
201
412
|
// Fallback to full scan. The store iterates rows in PK key order
|
|
202
413
|
// (see StoreTable.query / store.iterate over buildFullScanBounds), so
|
|
203
|
-
// the scan is monotonic on the leading PK column.
|
|
414
|
+
// the scan is monotonic on the leading PK column. `scanPlan` advertises that so
|
|
204
415
|
// downstream rules (merge-join, asof-scan) can fire on store-backed
|
|
205
416
|
// tables, matching memory-mode behavior.
|
|
417
|
+
return scanPlan;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* The `_primary_` multi-seek advertisement for a WHOLE-primary-key IN (`where pk in (…)`,
|
|
421
|
+
* or a runtime-valued set on the PK), or null when a gate declines it.
|
|
422
|
+
*
|
|
423
|
+
* `StoreTableScan.scanMultiSeekPrimary` is the runtime twin: it encodes one data key per
|
|
424
|
+
* tuple, deduplicates, sorts ascending by encoded key, and point-reads each in bounded
|
|
425
|
+
* batches. Both gates below mirror one of its `multiSeekMalformed` throws, which stay in
|
|
426
|
+
* place as the assertion that the plan never produced a shape it cannot serve.
|
|
427
|
+
*/
|
|
428
|
+
function primaryKeyMultiSeekPlan(tableInfo, request, pkColumns, pins, estimatedRows, pkOrderPreservingPrefix) {
|
|
429
|
+
if (pins.seekKeyCount > MAX_MULTI_SEEK_KEYS)
|
|
430
|
+
return null;
|
|
431
|
+
// Mirrors `StoreTableScan.pkHasSemanticOrderingMember`, which `scanMultiSeekPrimary`
|
|
432
|
+
// throws `multiSeekMalformed` on: N merged point windows ARE the whole access, with
|
|
433
|
+
// the residual already dropped, so an IN member with no faithful byte position would
|
|
434
|
+
// silently lose its tuple's rows. The single-value point arm has an escape this does
|
|
435
|
+
// not (it can decline to the scan arm per PROBE), which is why only this one declines
|
|
436
|
+
// per SCHEMA. Re-opening it is backlog `feat-store-semantic-key-multiseek`.
|
|
437
|
+
if (pkColumns.some(colIdx => hasSemanticOrdering(tableInfo.columns[colIdx]?.logicalType)))
|
|
438
|
+
return null;
|
|
439
|
+
// The PK is unique, so each seek key matches at most one row — no `multiRows` clamp
|
|
440
|
+
// artifact (contrast the secondary arm's), and no ROW_RESOLUTION_COST: the point read
|
|
441
|
+
// IS the row read, with no index-entry → row indirection to charge for.
|
|
442
|
+
//
|
|
443
|
+
// `Math.max(1, …)` is LOAD-BEARING, not defensive. `rows: 0` on a plan that claims
|
|
444
|
+
// every filter makes `rule-select-access-path` replace the whole table access with an
|
|
445
|
+
// `EmptyResultNode` (its "the module proved the predicate unsatisfiable" fold), so a
|
|
446
|
+
// table that is empty at PLAN time would return nothing for rows written by the same
|
|
447
|
+
// statement.
|
|
448
|
+
const rows = Math.max(1, Math.min(estimatedRows, pins.seekKeyCount));
|
|
206
449
|
const plan = AccessPlanBuilder
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
.
|
|
450
|
+
// `setIsSet(false)` is likewise load-bearing: `eqMatch` defaults `isSet` to
|
|
451
|
+
// `rows <= 1`, which a one-key runtime set would satisfy.
|
|
452
|
+
.eqMatch(rows, pins.seekKeyCount * INDEX_SEEK_COST)
|
|
453
|
+
.setIsSet(false)
|
|
454
|
+
.setIndexName('_primary_')
|
|
455
|
+
// EVERY primary-key column, in `primaryKeyDefinition` order: `scanMultiSeekPrimary`
|
|
456
|
+
// throws when `seekWidth` does not cover the whole key, and the rule derives
|
|
457
|
+
// `seekWidth` from this list.
|
|
458
|
+
.setSeekColumns(pkColumns)
|
|
459
|
+
.setHandledFilters(claimFirstPerRole(request.filters, equalityRoles(pkColumns)))
|
|
460
|
+
.setExplanation(`Store primary key multi-seek(${pins.seekKeyCount})`)
|
|
210
461
|
.build();
|
|
462
|
+
// This arm returns straight out of the PK branch, so it never reaches the
|
|
463
|
+
// seek-versus-scan comparison further down `computeBestAccessPlan` — the same
|
|
464
|
+
// exemption the secondary multi-seek arm gets explicitly, for the same reason:
|
|
465
|
+
// `rule-key-set-seek` reads this cost as a straight line at 2 and 1000 keys and
|
|
466
|
+
// abandons its rewrite if either probe stops naming an index. Do not "fix" this by
|
|
467
|
+
// adding the comparison.
|
|
468
|
+
//
|
|
469
|
+
// `scanMultiSeekPrimary` sorts its points ascending by encoded data key, which IS
|
|
470
|
+
// primary-key order (per-column DESC inversion is baked into the bytes), so the plan
|
|
471
|
+
// goes through the same ordering gate every other primary-key arm uses and
|
|
472
|
+
// `… where pk in (…) order by pk` elides its Sort.
|
|
211
473
|
return { ...plan, ...buildPkOrderingAdvertisement(tableInfo, request, pkOrderPreservingPrefix) };
|
|
212
474
|
}
|
|
213
475
|
/**
|
|
@@ -215,8 +477,9 @@ export function computeBestAccessPlan(db, tableInfo, request, tableKeyCollation)
|
|
|
215
477
|
* the index is not usable for this predicate.
|
|
216
478
|
*
|
|
217
479
|
* Usable = a contiguous leading-prefix EQ on the index columns (an index seek /
|
|
218
|
-
* point),
|
|
219
|
-
*
|
|
480
|
+
* point), a LT/LE/GT/GE range on the LEADING index column, or a strict leading-prefix EQ
|
|
481
|
+
* plus a LT/LE/GT/GE range on the NEXT index column (`prefixRange`, `plan=7`). These
|
|
482
|
+
* mirror the three windows `StoreTable.analyzeIndexAccess` can build.
|
|
220
483
|
*
|
|
221
484
|
* **Collation safety.** Index-column key bytes are encoded under each column's own key
|
|
222
485
|
* collation (`resolveIndexKeyCollations`); `StoreTable.matchesFilters` re-checks a fetched
|
|
@@ -229,13 +492,22 @@ export function computeBestAccessPlan(db, tableInfo, request, tableKeyCollation)
|
|
|
229
492
|
* the same resolution the residual uses); a collation-blind column (`json`, the
|
|
230
493
|
* temporal types) under an index column with an explicit non-BINARY COLLATE does not
|
|
231
494
|
* (its key bytes are hard-BINARY) and declines.
|
|
232
|
-
* - RANGE — {@link
|
|
233
|
-
* `orderPreserving` assertion, because a byte window also equates memcmp of
|
|
234
|
-
* bytes with the residual comparator's order.
|
|
495
|
+
* - RANGE — {@link indexRangeAtPositionIsOrderSafe}: the same agreement PLUS the
|
|
496
|
+
* collation's `orderPreserving` assertion, because a byte window also equates memcmp of
|
|
497
|
+
* the key bytes with the residual comparator's order. Asked at position 0 for a
|
|
498
|
+
* leading-column range, and at the prefix length for a `prefixRange` trailing bound —
|
|
499
|
+
* whose PREFIX additionally passes the EQUALITY gate above.
|
|
235
500
|
*
|
|
236
501
|
* `StoreTable.analyzeIndexAccess` gates its windows on the same two helpers, so the "mark
|
|
237
502
|
* handled" and "build a window" decisions cannot disagree. A decline returns a cost-only
|
|
238
503
|
* plan: cheaper cost, filters unhandled, residual retained; correct, just not sped up.
|
|
504
|
+
*
|
|
505
|
+
* **Cost.** Each single-window seek arm pays {@link ROW_RESOLUTION_COST} per row it expects
|
|
506
|
+
* to return, on top of the `AccessPlanBuilder` shape's own per-row term: the shape prices
|
|
507
|
+
* reading the index ENTRY, and the store must then read the ROW that entry names out of the
|
|
508
|
+
* data store. Two arms deliberately do NOT pay it — a cost-only decline (it resolves
|
|
509
|
+
* nothing; the scan reads rows directly) and the multi-seek, for the measured reason
|
|
510
|
+
* recorded at that arm below.
|
|
239
511
|
*/
|
|
240
512
|
function tryIndexAccessPlan(db, tableInfo, request, index, estimatedRows) {
|
|
241
513
|
// Exclude PARTIAL indexes from access planning: neither the engine nor this
|
|
@@ -247,65 +519,83 @@ function tryIndexAccessPlan(db, tableInfo, request, index, estimatedRows) {
|
|
|
247
519
|
if (index.predicate)
|
|
248
520
|
return null;
|
|
249
521
|
const indexColIndexes = index.columns.map(c => c.index);
|
|
250
|
-
// Contiguous leading-prefix equality → point/prefix seek. An IN filter counts as
|
|
251
|
-
//
|
|
252
|
-
// StoreTable.scanMultiSeek); `inCount` is the cross-product of the per-column
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
|
|
256
|
-
//
|
|
257
|
-
// A runtime-valued IN set contributes its `maxCount` ceiling — the worst case the
|
|
258
|
-
// engine may deliver — so every gate below (the MAX_MULTI_SEEK_KEYS cap, the
|
|
259
|
-
// semantic-ordering decline) judges it against the largest multi-seek it could
|
|
260
|
-
// ever be asked to perform.
|
|
261
|
-
const eqCols = [];
|
|
262
|
-
let inCount = 1;
|
|
263
|
-
// `isMultiSeek` is NOT `inCount > 1`: a runtime set is delivered as a `plan=5`
|
|
264
|
-
// multi-seek even at `maxCount === 1`, so the gates below must judge it as one
|
|
265
|
-
// rather than as the plain EQ its ceiling arithmetic would otherwise suggest.
|
|
266
|
-
// (`inCount > 1` implies this flag — every factor above 1 is a multi-value
|
|
267
|
-
// equality — so the flag only ever *adds* the maxCount-1 runtime-set case.)
|
|
268
|
-
let isMultiSeek = false;
|
|
269
|
-
for (const colIdx of indexColIndexes) {
|
|
270
|
-
const eqFilter = request.filters.find(f => f.columnIndex === colIdx && equalitySeekKeyCount(f) !== null);
|
|
271
|
-
if (!eqFilter)
|
|
272
|
-
break;
|
|
273
|
-
eqCols.push(colIdx);
|
|
274
|
-
inCount *= equalitySeekKeyCount(eqFilter);
|
|
275
|
-
if (isMultiValueEquality(eqFilter))
|
|
276
|
-
isMultiSeek = true;
|
|
277
|
-
}
|
|
522
|
+
// Contiguous leading-prefix equality → point/prefix seek. An IN filter counts as an
|
|
523
|
+
// equality here (it is N equalities, served as one multi-seek by
|
|
524
|
+
// StoreTable.scanMultiSeek); `inCount` is the cross-product of the per-column seek-key
|
|
525
|
+
// counts, matching the rule's seek-key count. See {@link resolveEqualityPins} for the
|
|
526
|
+
// positional pick and the runtime-set ceiling both arms share.
|
|
527
|
+
const { cols: eqCols, seekKeyCount: inCount, isMultiSeek } = resolveEqualityPins(request.filters, indexColIndexes);
|
|
278
528
|
const leadingCol = indexColIndexes[0];
|
|
279
529
|
const hasLeadingRange = request.filters.some(f => f.columnIndex === leadingCol && RANGE_OPS.includes(f.op));
|
|
530
|
+
// Prefix-equality + trailing-range: the equality prefix is a STRICT, non-empty prefix
|
|
531
|
+
// of the index columns and the NEXT index column carries a bound. `StoreTable
|
|
532
|
+
// .analyzeIndexAccess` builds the matching one-window `[prefix||bound]` range.
|
|
533
|
+
//
|
|
534
|
+
// A multi-value prefix is EXCLUDED: `rule-select-access-path` can only seek a
|
|
535
|
+
// single-valued prefix key, so `a in (1, 2) and b > 15` over `(a, b)` would come back
|
|
536
|
+
// as a sequential scan with both predicates residual — worse than the multi-seek this
|
|
537
|
+
// index can still serve on the IN alone. MemoryTableModule.evaluateIndexAccess records
|
|
538
|
+
// the same restriction for the same reason.
|
|
539
|
+
const nextCol = eqCols.length > 0 && eqCols.length < indexColIndexes.length
|
|
540
|
+
? indexColIndexes[eqCols.length]
|
|
541
|
+
: undefined;
|
|
542
|
+
const trailingRangeCol = (!isMultiSeek && nextCol !== undefined
|
|
543
|
+
&& request.filters.some(f => f.columnIndex === nextCol && RANGE_OPS.includes(f.op)))
|
|
544
|
+
? nextCol
|
|
545
|
+
: undefined;
|
|
280
546
|
let seekCols;
|
|
281
|
-
let
|
|
282
|
-
if (
|
|
547
|
+
let arm;
|
|
548
|
+
if (trailingRangeCol !== undefined) {
|
|
549
|
+
seekCols = [...eqCols, trailingRangeCol];
|
|
550
|
+
arm = 'prefixRange';
|
|
551
|
+
}
|
|
552
|
+
else if (eqCols.length > 0) {
|
|
283
553
|
seekCols = eqCols;
|
|
284
|
-
|
|
554
|
+
arm = 'eq';
|
|
285
555
|
}
|
|
286
556
|
else if (hasLeadingRange) {
|
|
287
557
|
seekCols = [leadingCol];
|
|
288
|
-
|
|
558
|
+
arm = 'range';
|
|
289
559
|
}
|
|
290
560
|
else {
|
|
291
561
|
return null; // this index cannot serve this predicate
|
|
292
562
|
}
|
|
293
|
-
const rows = isRange
|
|
294
|
-
? Math.max(1, Math.floor(estimatedRows * 0.3))
|
|
295
|
-
: Math.max(1, Math.floor(estimatedRows * 0.1));
|
|
296
|
-
const costOnly = (why) => (isRange ? AccessPlanBuilder.rangeScan(rows, 0.2) : AccessPlanBuilder.eqMatch(rows, 0.3))
|
|
297
|
-
.setHandledFilters(new Array(request.filters.length).fill(false))
|
|
298
|
-
.setExplanation(`Store index scan on ${index.name} (${why})`)
|
|
299
|
-
.build();
|
|
300
563
|
// Collation gates — see the doc comment above. `StoreTable.analyzeIndexAccess` declines
|
|
301
|
-
// exactly the same windows through the same two helpers.
|
|
564
|
+
// exactly the same windows through the same two helpers, at the same positions.
|
|
302
565
|
const indexKeyCollations = resolveIndexKeyCollations(index, tableInfo.columns);
|
|
303
|
-
|
|
304
|
-
|
|
566
|
+
// A trailing bound whose byte order does not reproduce its residual comparator's order
|
|
567
|
+
// costs only the bound, not the whole plan: DEGRADE to the equality-prefix seek and
|
|
568
|
+
// leave the bound unclaimed, so it survives in the residual Filter. The scan side makes
|
|
569
|
+
// the same move (a `plan=2` eqSeek arrives and it windows the prefix), and keeping the
|
|
570
|
+
// prefix seek is strictly better than the cost-only fallback below.
|
|
571
|
+
if (arm === 'prefixRange'
|
|
572
|
+
&& !indexRangeAtPositionIsOrderSafe(db, tableInfo.columns, index, indexKeyCollations, eqCols.length)) {
|
|
573
|
+
arm = 'eq';
|
|
574
|
+
seekCols = eqCols;
|
|
575
|
+
}
|
|
576
|
+
const isRange = arm !== 'eq';
|
|
577
|
+
const rows = Math.max(1, Math.floor(estimatedRows * ARM_SELECTIVITY[arm]));
|
|
578
|
+
/** The arm's shape — `AccessPlanBuilder`'s per-row index-entry term, no resolution. */
|
|
579
|
+
const armShape = () => isRange ? AccessPlanBuilder.rangeScan(rows, 0.2) : AccessPlanBuilder.eqMatch(rows, 0.3);
|
|
580
|
+
/** The arm's shape plus the per-fetched-row {@link ROW_RESOLUTION_COST}. */
|
|
581
|
+
const seekingArm = () => armShape().addCost(rows * ROW_RESOLUTION_COST);
|
|
582
|
+
const costOnly = (why) => ({
|
|
583
|
+
plan: armShape()
|
|
584
|
+
.setHandledFilters(new Array(request.filters.length).fill(false))
|
|
585
|
+
.setExplanation(`Store index scan on ${index.name} (${why})`)
|
|
586
|
+
.build(),
|
|
587
|
+
// Cost-only never reaches the comparison (it claims no seek columns), so the flag
|
|
588
|
+
// is immaterial; false states the fact — this plan seeks nothing at all.
|
|
589
|
+
isMultiSeek: false,
|
|
590
|
+
});
|
|
591
|
+
if (arm === 'range') {
|
|
592
|
+
if (!indexRangeAtPositionIsOrderSafe(db, tableInfo.columns, index, indexKeyCollations, 0)) {
|
|
305
593
|
return costOnly('cost-only; index range needs an order-preserving key collation');
|
|
306
594
|
}
|
|
307
595
|
}
|
|
308
|
-
else if (!indexPrefixSeekIsCollationExact(tableInfo.columns, index, indexKeyCollations,
|
|
596
|
+
else if (!indexPrefixSeekIsCollationExact(tableInfo.columns, index, indexKeyCollations, eqCols.length)) {
|
|
597
|
+
// Covers the `prefixRange` arm's PREFIX too: its pinned columns need exactly the
|
|
598
|
+
// equality arm's guarantee, and its trailing column was gated just above.
|
|
309
599
|
return costOnly('cost-only; index key collation differs from the comparison collation');
|
|
310
600
|
}
|
|
311
601
|
// Multi-seek declines. Cost-only keeps the residual, so the answer stays right
|
|
@@ -324,29 +614,68 @@ function tryIndexAccessPlan(db, tableInfo, request, index, estimatedRows) {
|
|
|
324
614
|
// backlog `feat-store-semantic-key-multiseek`.
|
|
325
615
|
return costOnly('cost-only; semantic-ordering seek column cannot multi-seek');
|
|
326
616
|
}
|
|
327
|
-
// Claim positionally — see {@link claimFirstPerRole}.
|
|
328
|
-
|
|
617
|
+
// Claim positionally — see {@link claimFirstPerRole}. The `prefixRange` arm claims the
|
|
618
|
+
// prefix equalities AND the first lower/upper bound on the trailing column; a redundant
|
|
619
|
+
// same-side bound (`date > a and date > b`) stays unclaimed and survives in the residual.
|
|
620
|
+
// A prefixRange DEGRADED to 'eq' above claims the prefix only, so its trailing bounds
|
|
621
|
+
// likewise survive.
|
|
622
|
+
const handledFilters = claimFirstPerRole(request.filters, arm === 'range'
|
|
623
|
+
? rangeRoles(leadingCol)
|
|
624
|
+
: arm === 'prefixRange'
|
|
625
|
+
? [...equalityRoles(eqCols), ...rangeRoles(trailingRangeCol)]
|
|
626
|
+
: equalityRoles(eqCols));
|
|
329
627
|
if (isMultiSeek) {
|
|
330
628
|
// Multi-seek (plan=5): inCount point seeks, `rows` matched per seek key. The
|
|
331
629
|
// per-seek positioning term keeps a 500-key IN over a 10-row table from pricing
|
|
332
630
|
// below a full scan and issuing 500 seeks to read 10 rows. `isSet` false mirrors
|
|
333
631
|
// MemoryTableModule.evaluateIndexAccess's setIsSet(!isMultiSeek). No ordering is
|
|
334
632
|
// advertised — window emission order is encoded-key order, not any column order.
|
|
633
|
+
//
|
|
634
|
+
// NOTE: this arm alone does NOT pay {@link ROW_RESOLUTION_COST}, though it resolves
|
|
635
|
+
// every entry it matches exactly like the single-window arms do. The reason is
|
|
636
|
+
// `multiRows`, not the physics: `inCount × rows` reaches `estimatedRows` at ten seek
|
|
637
|
+
// keys (`rows` is the fixed 0.1 × N equality shape constant) and at two or three on a
|
|
638
|
+
// handful-of-rows table, so beyond that the figure is a CLAMP — "the whole table" —
|
|
639
|
+
// rather than an estimate of what the keys match. Charging a per-row term against a
|
|
640
|
+
// clamp prices the estimator's artifact:
|
|
641
|
+
// - MEASURED: it fails 16 tests in `key-set-seek-store.spec.ts`, every one a
|
|
642
|
+
// runtime key-set semi join over a 3-to-4-row table that stops seeking.
|
|
643
|
+
// - The engine's `rule-key-set-seek` interpolates a break-even from THIS cost at 2
|
|
644
|
+
// and 1000 keys. With the term, `cost(1000 keys) = 500 + 1.3 × N` exceeds a scan's
|
|
645
|
+
// `N` for every N, so the break-even can never reach the engine's 1000-key ceiling
|
|
646
|
+
// — key sets above ~710 keys would stop seeking on a table of ANY size, including
|
|
647
|
+
// the 10M-row tables where an index seek is the entire point.
|
|
648
|
+
// The price of leaving it off is a narrower mis-ranking: against another index's `eq`
|
|
649
|
+
// arm (which does pay the term) a small-key IN now looks relatively cheaper than it
|
|
650
|
+
// is — `where a in (x, y) and b = ?` over a 1000-row table prices ix_a at 61 and ix_b
|
|
651
|
+
// at 130 and picks ix_a, though ix_a's own estimate says it fetches 200 rows to ix_b's
|
|
652
|
+
// 100. Charging the term everywhere fixes that ranking and breaks the feature above;
|
|
653
|
+
// the union estimate has to stop clamping before both can hold. See
|
|
654
|
+
// backlog/debt-store-multi-seek-union-row-estimate.
|
|
335
655
|
const multiRows = Math.min(estimatedRows, inCount * rows);
|
|
336
|
-
return
|
|
337
|
-
.
|
|
656
|
+
return {
|
|
657
|
+
plan: AccessPlanBuilder.eqMatch(multiRows, inCount * INDEX_SEEK_COST)
|
|
658
|
+
.setIsSet(false)
|
|
659
|
+
.setHandledFilters(handledFilters)
|
|
660
|
+
.setIndexName(index.name)
|
|
661
|
+
.setSeekColumns(seekCols)
|
|
662
|
+
.setExplanation(`Store index multi-seek(${inCount}) on ${index.name}`)
|
|
663
|
+
.build(),
|
|
664
|
+
isMultiSeek: true,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
const armLabel = arm === 'prefixRange'
|
|
668
|
+
? `prefix-range seek(prefix=${eqCols.length})`
|
|
669
|
+
: arm === 'range' ? 'range scan' : 'seek';
|
|
670
|
+
return {
|
|
671
|
+
plan: seekingArm()
|
|
338
672
|
.setHandledFilters(handledFilters)
|
|
339
673
|
.setIndexName(index.name)
|
|
340
674
|
.setSeekColumns(seekCols)
|
|
341
|
-
.setExplanation(`Store index
|
|
342
|
-
.build()
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
.setHandledFilters(handledFilters)
|
|
346
|
-
.setIndexName(index.name)
|
|
347
|
-
.setSeekColumns(seekCols)
|
|
348
|
-
.setExplanation(`Store index ${isRange ? 'range scan' : 'seek'} on ${index.name}`)
|
|
349
|
-
.build();
|
|
675
|
+
.setExplanation(`Store index ${armLabel} on ${index.name}`)
|
|
676
|
+
.build(),
|
|
677
|
+
isMultiSeek: false,
|
|
678
|
+
};
|
|
350
679
|
}
|
|
351
680
|
/**
|
|
352
681
|
* Compute the PK-ordering advertisement for a scan-style plan. Returns the
|