@quereus/store 4.11.0 → 4.12.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 +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
|
@@ -11,8 +11,44 @@ import { IndexConstraintOp, QuereusError, StatusCode, compareSqlValuesFast, crea
|
|
|
11
11
|
import { bytesToHex, compareBytes } from './bytes.js';
|
|
12
12
|
import { buildIndexPrefixBounds, buildFullScanBounds, } from './key-builder.js';
|
|
13
13
|
import { deserializeRow, } from './serialization.js';
|
|
14
|
-
import {
|
|
14
|
+
import { indexPrefixSeekIsCollationExact, indexRangeAtPositionIsOrderSafe, pkOrderPreservingPrefixLength, resolveIndexKeyCollations, resolveIndexKeyTransforms, semanticProbeIsKeyFaithful } from './pk-key-resolution.js';
|
|
15
15
|
import { StoreTableBase } from './store-table-base.js';
|
|
16
|
+
/**
|
|
17
|
+
* The pushed constraint ops that bound a byte window from one side. The scan-side twin of
|
|
18
|
+
* `store-module-access-plan.ts`'s `RANGE_OPS` (which names the same four in the planner's
|
|
19
|
+
* `PredicateConstraint` spelling), shared by all three window builders below so a new
|
|
20
|
+
* bound operator cannot reach one arm and not the others.
|
|
21
|
+
*/
|
|
22
|
+
const RANGE_CONSTRAINT_OPS = [
|
|
23
|
+
IndexConstraintOp.LT,
|
|
24
|
+
IndexConstraintOp.LE,
|
|
25
|
+
IndexConstraintOp.GT,
|
|
26
|
+
IndexConstraintOp.GE,
|
|
27
|
+
];
|
|
28
|
+
/**
|
|
29
|
+
* Index entries per row-resolution batch: the scan arms below collect up to this many
|
|
30
|
+
* surviving index entries (or multi-seek point keys) and resolve them with ONE
|
|
31
|
+
* {@link StoreTableBase.readEffectiveRowsByKeys} call, so an indexed read of N rows
|
|
32
|
+
* costs on the order of N / 256 store round trips instead of N serialized point reads
|
|
33
|
+
* (on IndexedDB, one readonly transaction instead of N). This is the bound
|
|
34
|
+
* {@link KVStore.getMany}'s "bounded by the caller" note refers to.
|
|
35
|
+
*
|
|
36
|
+
* 256 matches the iterate pager's default page (`paged-iterate.ts` DEFAULT_BATCH_SIZE)
|
|
37
|
+
* and makes the same trade that pager already makes and the {@link KVStore.iterate}
|
|
38
|
+
* contract documents: a little early-termination cost for throughput — `limit 1`
|
|
39
|
+
* collects one batch rather than one row — while keeping peak memory independent of
|
|
40
|
+
* the range size.
|
|
41
|
+
*
|
|
42
|
+
* NOTE: that early-termination cost is bounded but not free — `limit 1` deserializes up
|
|
43
|
+
* to 256 rows to yield one, and holds them all at once. It is charged against ROUND
|
|
44
|
+
* TRIPS, which is what dominates on the backends this exists for (the index side already
|
|
45
|
+
* pages 256 entries per read, so a stopped scan costs one data round trip either way),
|
|
46
|
+
* so it is accepted as-is. Revisit if a `limit`-shaped query (a correlated `EXISTS`, a
|
|
47
|
+
* semi-join probe) shows up in a profile as row-decode or allocation cost: the fix is a
|
|
48
|
+
* SMALLER FIRST batch that ramps toward this bound, which keeps a drain's round-trip
|
|
49
|
+
* count within a log factor while making a stopped scan cost ~one row again.
|
|
50
|
+
*/
|
|
51
|
+
export const ROW_RESOLUTION_BATCH = 256;
|
|
16
52
|
/**
|
|
17
53
|
* Read-path layer of the generic KVStore-backed virtual table.
|
|
18
54
|
*
|
|
@@ -146,8 +182,7 @@ export class StoreTableScan extends StoreTableBase {
|
|
|
146
182
|
}
|
|
147
183
|
// Check for range constraints on first PK column
|
|
148
184
|
const firstPkCol = pkColumns[0];
|
|
149
|
-
const
|
|
150
|
-
const rangeConstraints = filterInfo.constraints?.filter(c => c.constraint.iColumn === firstPkCol && rangeOps.includes(c.constraint.op)) || [];
|
|
185
|
+
const rangeConstraints = filterInfo.constraints?.filter(c => c.constraint.iColumn === firstPkCol && RANGE_CONSTRAINT_OPS.includes(c.constraint.op)) || [];
|
|
151
186
|
// A range window is only sound when the leading PK column's key bytes order the way
|
|
152
187
|
// its comparator does; otherwise fall through to the full scan, where matchesFilters
|
|
153
188
|
// applies the range under the real comparator.
|
|
@@ -286,24 +321,32 @@ export class StoreTableScan extends StoreTableBase {
|
|
|
286
321
|
* Analyze filter info to determine a secondary-index access pattern, mirroring
|
|
287
322
|
* {@link analyzePKAccess} but over the index chosen in `idxStr`.
|
|
288
323
|
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
324
|
+
* Three window shapes, in the order they are tried:
|
|
325
|
+
*
|
|
326
|
+
* - a contiguous leading-prefix EQ plus a LT/LE/GT/GE bound on the NEXT index column
|
|
327
|
+
* (the `prefixRangeSeek` / `plan=7` plan) — one `range` window covering just the
|
|
328
|
+
* bounded slice INSIDE the prefix;
|
|
329
|
+
* - a contiguous leading-prefix EQ alone → a `point` window (the prefix covers every
|
|
330
|
+
* entry sharing those leading values — an index seek is a PREFIX scan, not a single
|
|
331
|
+
* row, since the index need not be unique and the PK suffix varies);
|
|
332
|
+
* - otherwise a range (LT/LE/GT/GE) on the LEADING index column → a `range` window.
|
|
333
|
+
*
|
|
334
|
+
* Returns null when none applies or when the index is unresolved.
|
|
335
|
+
* {@link matchesFilters} stays the authoritative row filter, so the window need only be
|
|
336
|
+
* a SUPERSET — which is why every degraded path below WIDENS (a prefix-range that
|
|
337
|
+
* cannot narrow falls back to the prefix window, a prefix that cannot encode a member
|
|
338
|
+
* stops short) rather than declining outright.
|
|
296
339
|
*
|
|
297
340
|
* Index-column bytes are encoded under each column's own key collation
|
|
298
341
|
* ({@link indexKeyCollations}); {@link matchesFilters} re-checks a fetched row under the
|
|
299
342
|
* index column's `COLLATE` else the table column's declared collation. The EQ/prefix
|
|
300
343
|
* window is EXACTLY the qualifying set when those two agree, which
|
|
301
|
-
* {@link indexPrefixSeekIsCollationExact} decides;
|
|
302
|
-
* memcmp of the key bytes with the residual's comparison ORDER
|
|
303
|
-
* {@link indexRangeIsOrderSafe} decides. `tryIndexAccessPlan`
|
|
304
|
-
* (store-module-access-plan.ts) consults the SAME two predicates
|
|
305
|
-
* filters handled, so the two decisions cannot disagree.
|
|
306
|
-
* the caller full-scans, where the residual is authoritative.
|
|
344
|
+
* {@link indexPrefixSeekIsCollationExact} decides; a RANGE window additionally equates
|
|
345
|
+
* memcmp of the key bytes with the residual's comparison ORDER at the bounded column's
|
|
346
|
+
* position, which {@link indexRangeIsOrderSafe} decides. `tryIndexAccessPlan`
|
|
347
|
+
* (store-module-access-plan.ts) consults the SAME two predicates, at the same positions,
|
|
348
|
+
* before claiming the filters handled, so the two decisions cannot disagree. An EQ-prefix
|
|
349
|
+
* failure returns null and the caller full-scans, where the residual is authoritative.
|
|
307
350
|
*/
|
|
308
351
|
analyzeIndexAccess(filterInfo) {
|
|
309
352
|
const index = this.resolveIndexFromIdxStr(filterInfo.idxStr);
|
|
@@ -338,22 +381,96 @@ export class StoreTableScan extends StoreTableBase {
|
|
|
338
381
|
if (!indexPrefixSeekIsCollationExact(this.tableSchema.columns, index, indexCollations, eqValues.length)) {
|
|
339
382
|
return null;
|
|
340
383
|
}
|
|
384
|
+
// Prefix-equality + trailing-range. `prefixLen` comes from the idxStr rather
|
|
385
|
+
// than being re-inferred from the constraint list, so a plan that meant a
|
|
386
|
+
// different prefix length is caught instead of silently windowing the wrong
|
|
387
|
+
// columns. `prefixLen > eqValues.length` means an unfaithful semantic probe cut
|
|
388
|
+
// the prefix short above; the bound then belongs to a column the window no
|
|
389
|
+
// longer reaches, so it is dropped and the shorter prefix window (a strict
|
|
390
|
+
// superset) serves — {@link matchesFilters} still applies it.
|
|
391
|
+
const prefixLen = this.prefixRangeSeekLength(filterInfo, index);
|
|
392
|
+
if (prefixLen !== null && prefixLen <= eqValues.length) {
|
|
393
|
+
const bounds = this.buildPrefixRangeWindow(filterInfo, index, eqValues.slice(0, prefixLen));
|
|
394
|
+
if (bounds)
|
|
395
|
+
return { index, type: 'range', bounds };
|
|
396
|
+
}
|
|
341
397
|
const bounds = buildIndexPrefixBounds(eqValues, this.encodeOptions, indexDirections.slice(0, eqValues.length), indexCollations.slice(0, eqValues.length), this.indexKeyTransforms(index).slice(0, eqValues.length));
|
|
342
398
|
return { index, type: 'point', bounds };
|
|
343
399
|
}
|
|
344
400
|
// Else a range on the LEADING index column.
|
|
345
401
|
const leadingCol = indexCols[0];
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const bounds = this.buildIndexRangeBounds(rangeConstraints.map(c => ({
|
|
350
|
-
op: c.constraint.op,
|
|
351
|
-
value: c.argvIndex > 0 ? filterInfo.args[c.argvIndex - 1] : undefined,
|
|
352
|
-
})), indexDirections[0], indexCollations[0], this.tableSchema.columns[leadingCol]?.logicalType, this.indexKeyTransforms(index)[0]);
|
|
402
|
+
const rangeConstraints = this.rangeConstraintsOn(filterInfo, leadingCol);
|
|
403
|
+
if (rangeConstraints.length > 0 && this.indexRangeIsOrderSafe(index, 0)) {
|
|
404
|
+
const bounds = this.buildIndexRangeBounds([], rangeConstraints, indexDirections.slice(0, 1), indexCollations.slice(0, 1), this.tableSchema.columns[leadingCol]?.logicalType, this.indexKeyTransforms(index).slice(0, 1));
|
|
353
405
|
return { index, type: 'range', bounds };
|
|
354
406
|
}
|
|
355
407
|
return null;
|
|
356
408
|
}
|
|
409
|
+
/**
|
|
410
|
+
* The prefix length a `prefixRangeSeek` (`plan=7`) FilterInfo declares, or null when
|
|
411
|
+
* this FilterInfo is not one.
|
|
412
|
+
*
|
|
413
|
+
* Honoring the plan's own `prefixLen` — rather than re-deriving it from the constraint
|
|
414
|
+
* list — is what makes a plan/scan disagreement LOUD: a value outside `1 …
|
|
415
|
+
* index.columns.length - 1` cannot describe a window over this index, and the plan
|
|
416
|
+
* already dropped the residual for the filters it claimed, so guessing a length would
|
|
417
|
+
* silently address the wrong columns.
|
|
418
|
+
*/
|
|
419
|
+
prefixRangeSeekLength(filterInfo, index) {
|
|
420
|
+
const spec = decodeIdxStr(filterInfo.idxStr);
|
|
421
|
+
if (!spec || planKindFromCode(spec.plan) !== 'prefixRangeSeek')
|
|
422
|
+
return null;
|
|
423
|
+
const raw = spec.params.get('prefixLen');
|
|
424
|
+
const prefixLen = Number.parseInt(raw ?? '', 10);
|
|
425
|
+
if (!Number.isInteger(prefixLen) || prefixLen < 1 || prefixLen >= index.columns.length) {
|
|
426
|
+
this.malformedFilterInfo('prefix-range seek', filterInfo, `prefixLen=${raw} against ${index.name}'s ${index.columns.length} columns`);
|
|
427
|
+
}
|
|
428
|
+
return prefixLen;
|
|
429
|
+
}
|
|
430
|
+
/** The pushed LT/LE/GT/GE constraints on `columnIndex`, paired with their bound values. */
|
|
431
|
+
rangeConstraintsOn(filterInfo, columnIndex) {
|
|
432
|
+
return (filterInfo.constraints ?? [])
|
|
433
|
+
.filter(c => c.constraint.iColumn === columnIndex && RANGE_CONSTRAINT_OPS.includes(c.constraint.op))
|
|
434
|
+
.map(c => ({
|
|
435
|
+
op: c.constraint.op,
|
|
436
|
+
value: c.argvIndex > 0 ? filterInfo.args[c.argvIndex - 1] : undefined,
|
|
437
|
+
}));
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* The narrowed window for a prefix-equality + trailing-range seek: the equality prefix
|
|
441
|
+
* bytes, further bounded by the LT/LE/GT/GE constraints on the index column right after
|
|
442
|
+
* it. Null when the trailing bound cannot be used, in which case the caller falls back
|
|
443
|
+
* to the plain prefix window — a strict SUPERSET, so the answer is unchanged and only
|
|
444
|
+
* the narrowing is lost. Two reasons for that:
|
|
445
|
+
*
|
|
446
|
+
* - the trailing column's key bytes do not reproduce its residual comparator's ORDER
|
|
447
|
+
* ({@link indexRangeIsOrderSafe}) — `tryIndexAccessPlan` degrades to the plain prefix
|
|
448
|
+
* seek through the same predicate, so this is the matching scan-side move rather than
|
|
449
|
+
* a disagreement;
|
|
450
|
+
* - no bound constraint survived on that column at all (nothing to narrow with).
|
|
451
|
+
*/
|
|
452
|
+
buildPrefixRangeWindow(filterInfo, index, prefixValues) {
|
|
453
|
+
const position = prefixValues.length;
|
|
454
|
+
const trailingCol = index.columns[position]?.index;
|
|
455
|
+
if (trailingCol === undefined)
|
|
456
|
+
return null;
|
|
457
|
+
if (!this.indexRangeIsOrderSafe(index, position))
|
|
458
|
+
return null;
|
|
459
|
+
// NOTE: a `plan=7` with NO bound on the trailing column is as structurally impossible
|
|
460
|
+
// as a bad `prefixLen` — `rule-select-access-path` only emits the plan once it has
|
|
461
|
+
// found a lower or upper bound, and `seekColumns` is advertised in index-column
|
|
462
|
+
// order, so `index.columns[prefixLen]` IS the column the rule bounded. It stays SOFT
|
|
463
|
+
// anyway because the two impossibilities fail differently: a wrong `prefixLen` would
|
|
464
|
+
// address the wrong columns (an under-fetch, a wrong answer), while a missing bound
|
|
465
|
+
// only fails to narrow. If a plan source ever does hand this over, the symptom is a
|
|
466
|
+
// slow query, not a wrong one — make it loud only if that silence starts hiding a
|
|
467
|
+
// real disagreement.
|
|
468
|
+
const rangeConstraints = this.rangeConstraintsOn(filterInfo, trailingCol);
|
|
469
|
+
if (rangeConstraints.length === 0)
|
|
470
|
+
return null;
|
|
471
|
+
const width = position + 1;
|
|
472
|
+
return this.buildIndexRangeBounds(prefixValues, rangeConstraints, index.columns.slice(0, width).map(c => !!c.desc), this.indexKeyCollations(index).slice(0, width), this.tableSchema.columns[trailingCol]?.logicalType, this.indexKeyTransforms(index).slice(0, width));
|
|
473
|
+
}
|
|
357
474
|
/**
|
|
358
475
|
* Lazy cache behind {@link indexKeyCollations}: one resolved array per index-schema
|
|
359
476
|
* object, invalidated when the column array it was resolved against is replaced (an
|
|
@@ -443,25 +560,37 @@ export class StoreTableScan extends StoreTableBase {
|
|
|
443
560
|
});
|
|
444
561
|
}
|
|
445
562
|
/**
|
|
446
|
-
* True when a byte window
|
|
447
|
-
* residual comparison uses. Delegates to {@link
|
|
448
|
-
*
|
|
449
|
-
* it marks the range filters handled, so the "build a window" and "mark
|
|
450
|
-
* decisions cannot disagree. The memoized {@link indexKeyCollations} is passed
|
|
451
|
-
* key side, so the predicate judges the bytes this scan actually addresses.
|
|
563
|
+
* True when a byte window bounded on index column `position` reproduces the order the
|
|
564
|
+
* residual comparison uses there. Delegates to {@link indexRangeAtPositionIsOrderSafe} —
|
|
565
|
+
* the SAME call `tryIndexAccessPlan` (store-module-access-plan.ts) makes, at the same
|
|
566
|
+
* position, before it marks the range filters handled, so the "build a window" and "mark
|
|
567
|
+
* handled" decisions cannot disagree. The memoized {@link indexKeyCollations} is passed
|
|
568
|
+
* as the key side, so the predicate judges the bytes this scan actually addresses.
|
|
569
|
+
*
|
|
570
|
+
* `position` is 0 for a standalone leading-column range and the prefix length for a
|
|
571
|
+
* prefix-equality + trailing-range window.
|
|
452
572
|
*/
|
|
453
|
-
indexRangeIsOrderSafe(index) {
|
|
454
|
-
return
|
|
573
|
+
indexRangeIsOrderSafe(index, position) {
|
|
574
|
+
return indexRangeAtPositionIsOrderSafe(this.db, this.tableSchema.columns, index, this.indexKeyCollations(index), position);
|
|
455
575
|
}
|
|
456
576
|
/**
|
|
457
|
-
* Convert
|
|
458
|
-
* `gte`/`lt` window
|
|
577
|
+
* Convert LT/LE/GT/GE constraints on ONE index column into a single encoded-byte
|
|
578
|
+
* `gte`/`lt` window, optionally nested inside a fixed equality prefix — the
|
|
579
|
+
* secondary-index analogue of {@link buildPKRangeBounds}.
|
|
459
580
|
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
581
|
+
* The bounded column sits at position `prefixValues.length`; `directions`,
|
|
582
|
+
* `collations` and `transforms` are the index's own per-column arrays sliced to cover
|
|
583
|
+
* the prefix AND that column. An empty `prefixValues` is the standalone leading-column
|
|
584
|
+
* range; a non-empty one is the trailing bound of a `prefixRangeSeek`.
|
|
585
|
+
*
|
|
586
|
+
* Each bound value is encoded BEHIND the prefix, under the bounded column's own key
|
|
587
|
+
* collation ({@link indexKeyCollations}) and DESC direction — exactly as
|
|
588
|
+
* {@link buildIndexKey} encodes that column — via {@link buildIndexPrefixBounds},
|
|
589
|
+
* giving the byte region `[lo, hi)` whose prefix columns equal `prefixValues` and whose
|
|
590
|
+
* bounded column equals that value. The op maps that region's endpoints onto
|
|
591
|
+
* `gte`/`lt`, with the same DESC lower/upper SWAP as the PK path — driven by the BOUNDED
|
|
592
|
+
* column's direction alone, since a DESC prefix column merely inverts bytes *inside* a
|
|
593
|
+
* prefix that stays fixed:
|
|
465
594
|
*
|
|
466
595
|
* | op | ASC | DESC |
|
|
467
596
|
* |----|----------|----------|
|
|
@@ -470,29 +599,34 @@ export class StoreTableScan extends StoreTableBase {
|
|
|
470
599
|
* | LE | lt = hi | gte = lo |
|
|
471
600
|
* | LT | lt = lo | gte = hi |
|
|
472
601
|
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
*
|
|
477
|
-
*
|
|
478
|
-
*
|
|
479
|
-
*
|
|
480
|
-
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
602
|
+
* Both sides START at the prefix's own window (`[P, incr(P))`, or unbounded for an
|
|
603
|
+
* empty prefix) and are then tightened: keep the MAX lower and MIN upper across
|
|
604
|
+
* constraints. So every degraded path lands back on the prefix window, never wider:
|
|
605
|
+
* an `undefined` upper (an `hi` whose increment overflowed all-0xff), a NULL/missing
|
|
606
|
+
* bound value (the planner never pushes `= NULL`, and a range op against NULL rejects
|
|
607
|
+
* every row in matchesFilters), and a bound over a semantic-ordering column
|
|
608
|
+
* (`logicalType`) whose VALUE has no faithful byte position
|
|
609
|
+
* ({@link semanticProbeIsKeyFaithful}) are all simply skipped — a dropped RANGE bound
|
|
610
|
+
* only widens the window, and matchesFilters reproduces the predicate under the type's
|
|
611
|
+
* compare; see the widen-vs-decline note on {@link buildPKRangeBounds}. `transforms`
|
|
612
|
+
* carry each column's key value transform ({@link indexKeyTransforms}), so a surviving
|
|
613
|
+
* bound addresses the same transformed bytes the index store holds.
|
|
484
614
|
*/
|
|
485
|
-
buildIndexRangeBounds(constraints,
|
|
486
|
-
const
|
|
487
|
-
|
|
488
|
-
|
|
615
|
+
buildIndexRangeBounds(prefixValues, constraints, directions, collations, logicalType, transforms) {
|
|
616
|
+
const position = prefixValues.length;
|
|
617
|
+
const dir = directions[position];
|
|
618
|
+
// The prefix's own window is the fallback for BOTH sides: `buildIndexPrefixBounds`
|
|
619
|
+
// answers full-scan bounds for an empty prefix, reproducing the leading-column arm.
|
|
620
|
+
const base = buildIndexPrefixBounds([...prefixValues], this.encodeOptions, directions.slice(0, position), collations.slice(0, position), transforms.slice(0, position));
|
|
621
|
+
let gte = base.gte;
|
|
622
|
+
let lt = base.lt;
|
|
489
623
|
for (const c of constraints) {
|
|
490
624
|
if (c.value === undefined || c.value === null)
|
|
491
625
|
continue;
|
|
492
626
|
// Widen, don't decline — see the doc comment above.
|
|
493
627
|
if (!semanticProbeIsKeyFaithful(logicalType, c.value))
|
|
494
628
|
continue;
|
|
495
|
-
const { gte: lo, lt: hi } = buildIndexPrefixBounds([c.value], this.encodeOptions,
|
|
629
|
+
const { gte: lo, lt: hi } = buildIndexPrefixBounds([...prefixValues, c.value], this.encodeOptions, directions, collations, transforms);
|
|
496
630
|
const lower = !dir
|
|
497
631
|
? (c.op === IndexConstraintOp.GE ? lo : c.op === IndexConstraintOp.GT ? hi : undefined)
|
|
498
632
|
: (c.op === IndexConstraintOp.LE ? lo : c.op === IndexConstraintOp.LT ? hi : undefined);
|
|
@@ -510,27 +644,36 @@ export class StoreTableScan extends StoreTableBase {
|
|
|
510
644
|
* Scan a secondary index over `access.bounds`, resolving each index entry to its
|
|
511
645
|
* base row and re-filtering.
|
|
512
646
|
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
* row (the entry's row was deleted — a pending index delete would normally
|
|
521
|
-
* suppress the entry, but a committed entry can lag) is skipped, and every
|
|
522
|
-
* resolved row is re-checked by {@link matchesFilters} (the byte window is only
|
|
523
|
-
* a superset, and a stale entry whose indexed column no longer matches is
|
|
524
|
-
* dropped).
|
|
647
|
+
* One window of the producer/consumer pair below: {@link produceIndexEntries}
|
|
648
|
+
* walks the window's index entries and {@link resolveIndexEntries} resolves them
|
|
649
|
+
* to rows in {@link ROW_RESOLUTION_BATCH}-bounded batches — one
|
|
650
|
+
* {@link StoreTableBase.readEffectiveRowsByKeys} round trip per batch instead of
|
|
651
|
+
* one awaited point read per entry. A multi-seek runs the SAME consumer over the
|
|
652
|
+
* concatenation of every window's producer instead of calling this method — see
|
|
653
|
+
* {@link scanMultiSeek}.
|
|
525
654
|
*/
|
|
526
|
-
async *scanIndex(indexStore, access, filterInfo
|
|
655
|
+
async *scanIndex(indexStore, access, filterInfo) {
|
|
527
656
|
// Re-check each resolved row under the INDEX's per-column collation (see
|
|
528
657
|
// matchesFilters): the planner dropped the residual based on the index
|
|
529
658
|
// column's collation, which an explicit index `COLLATE` can make differ from
|
|
530
659
|
// the table column's declared collation.
|
|
531
|
-
const indexCollations =
|
|
532
|
-
|
|
533
|
-
|
|
660
|
+
const indexCollations = this.resolveFilterCollations(filterInfo, this.indexColumnCollations(access.index));
|
|
661
|
+
yield* this.resolveIndexEntries(this.produceIndexEntries(indexStore, access.bounds, [filterInfo]), indexCollations);
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Producer half of the index-scan pair: walk one byte window's index entries —
|
|
665
|
+
* {@link iterateEffective} yields the committed entries merged with this
|
|
666
|
+
* transaction's pending index puts/deletes (read-your-own-writes over the index),
|
|
667
|
+
* in index-key byte order, the order the isolation overlay merge relies on
|
|
668
|
+
* (`isolated-table.ts` § buildSortKey) — and yield, per surviving entry, its data
|
|
669
|
+
* key plus the FilterInfos a resolved row may match. Entries stay in visit order
|
|
670
|
+
* end to end, so index-key order is preserved through the batching consumer.
|
|
671
|
+
*
|
|
672
|
+
* Only the defenses that need nothing but the entry itself run here, BEFORE the
|
|
673
|
+
* row read; everything row-dependent lives in {@link resolveRowBatch}.
|
|
674
|
+
*/
|
|
675
|
+
async *produceIndexEntries(indexStore, bounds, infos, seen) {
|
|
676
|
+
for await (const entry of this.iterateEffective(indexStore, bounds)) {
|
|
534
677
|
// NOTE: a legacy index store (written before index values carried the data
|
|
535
678
|
// key) holds EMPTY values; a zero-length data key is not a row key, so skip
|
|
536
679
|
// it rather than resolve it to the wrong row. Because the access plan marked
|
|
@@ -544,31 +687,97 @@ export class StoreTableScan extends StoreTableBase {
|
|
|
544
687
|
// fall back to a full scan the first time an empty value is seen.
|
|
545
688
|
if (entry.value.length === 0)
|
|
546
689
|
continue;
|
|
547
|
-
// Cross-window dedup for a multi-seek: a data key an
|
|
690
|
+
// Cross-window dedup for a multi-seek: a data key an EARLIER window already
|
|
548
691
|
// YIELDED is skipped before the data-store read, so a duplicate costs no
|
|
549
|
-
// extra
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
692
|
+
// extra point read. This is only a PRE-check — a duplicate inside one
|
|
693
|
+
// not-yet-resolved batch is invisible to it and is caught by
|
|
694
|
+
// {@link resolveRowBatch}'s post-read re-check. The seen-set is only ever
|
|
695
|
+
// ADDED to on a yield (there) — adding at visit time would let a stale index
|
|
696
|
+
// entry that fails its residual poison the set and suppress the row's live
|
|
697
|
+
// entry in a later window.
|
|
698
|
+
let dataKeyHex;
|
|
699
|
+
if (seen) {
|
|
700
|
+
dataKeyHex = bytesToHex(entry.value);
|
|
701
|
+
if (seen.has(dataKeyHex))
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
yield { dataKey: entry.value, dataKeyHex, infos };
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Consumer half of the index-scan pair: collect up to {@link ROW_RESOLUTION_BATCH}
|
|
709
|
+
* produced entries and resolve each batch through {@link resolveRowBatch} — one
|
|
710
|
+
* store round trip per batch. Batching changes the round-trip count, never the row
|
|
711
|
+
* set or its order: batches are resolved and yielded in entry order.
|
|
712
|
+
*
|
|
713
|
+
* Early termination: a downstream `break` mid-batch finishes this generator, whose
|
|
714
|
+
* `for await` closes `entries` — and the iterate cursor under it — on the way out;
|
|
715
|
+
* no further batch is collected or read.
|
|
716
|
+
*/
|
|
717
|
+
async *resolveIndexEntries(entries, collations, seen) {
|
|
718
|
+
let batch = [];
|
|
719
|
+
for await (const entry of entries) {
|
|
720
|
+
batch.push(entry);
|
|
721
|
+
if (batch.length >= ROW_RESOLUTION_BATCH) {
|
|
722
|
+
yield* this.resolveRowBatch(batch, collations, seen);
|
|
723
|
+
batch = [];
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
if (batch.length > 0) {
|
|
727
|
+
yield* this.resolveRowBatch(batch, collations, seen);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Resolve one bounded batch of index entries to rows — a single
|
|
732
|
+
* {@link StoreTableBase.readEffectiveRowsByKeys} call (which applies this
|
|
733
|
+
* transaction's pending deletes/puts BEFORE touching the committed store; the
|
|
734
|
+
* precedence is not re-implemented here) — then run the row-dependent defenses in
|
|
735
|
+
* entry order, mirroring the memory layer's live-recheck:
|
|
736
|
+
*
|
|
737
|
+
* - a resolved-null row (the entry's row was deleted — a pending index delete
|
|
738
|
+
* would normally suppress the entry, but a committed entry can lag) is skipped,
|
|
739
|
+
* and cannot shift later positions (the read answers positionally);
|
|
740
|
+
* - the multi-seek `seen` set is RE-checked after the read: the producer's
|
|
741
|
+
* pre-check ran before this batch was collected, so two entries at one data key
|
|
742
|
+
* inside a single batch (a stale index entry) are only caught here — without
|
|
743
|
+
* the re-check the row would yield twice;
|
|
744
|
+
* - a row is accepted when it matches ANY of the entry's FilterInfos (the byte
|
|
745
|
+
* window is only a superset, and a stale entry whose indexed column no longer
|
|
746
|
+
* matches is dropped), and `seen` is added to on yield ALONE — a stale entry
|
|
747
|
+
* that fails its residual must not suppress the row's live entry later.
|
|
748
|
+
*
|
|
749
|
+
* NOTE: still one data-store point read per matched index entry — the row lives in
|
|
750
|
+
* the data store, not the index (the index value carries only the data key, no
|
|
751
|
+
* covering payload); batching collapses the reads' round trips, not their count.
|
|
752
|
+
* If index-covered scans ever dominate a profile, consider storing the serialized
|
|
753
|
+
* row as a covering index value, at the cost of an index rewrite on EVERY column
|
|
754
|
+
* change (not just indexed columns) — deliberately not done here.
|
|
755
|
+
*/
|
|
756
|
+
async *resolveRowBatch(batch, collations, seen) {
|
|
757
|
+
const rows = await this.readEffectiveRowsByKeys(batch.map(e => e.dataKey));
|
|
758
|
+
for (let i = 0; i < batch.length; i++) {
|
|
759
|
+
const row = rows[i];
|
|
562
760
|
if (!row)
|
|
563
761
|
continue;
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
762
|
+
const { dataKeyHex, infos } = batch[i];
|
|
763
|
+
if (seen && dataKeyHex !== undefined && seen.has(dataKeyHex))
|
|
764
|
+
continue;
|
|
765
|
+
if (infos.some(fi => this.matchesFilters(row, fi, collations))) {
|
|
766
|
+
if (seen && dataKeyHex !== undefined)
|
|
767
|
+
seen.add(dataKeyHex);
|
|
568
768
|
yield row;
|
|
569
769
|
}
|
|
570
770
|
}
|
|
571
771
|
}
|
|
772
|
+
/**
|
|
773
|
+
* Fail loudly on a structurally impossible FilterInfo — one whose own idxStr describes
|
|
774
|
+
* an access this table's schema cannot express. Not a soundness fallback: everything
|
|
775
|
+
* that is merely UNSERVEABLE degrades to a wider window plus the {@link matchesFilters}
|
|
776
|
+
* residual instead of raising. `kind` names the plan shape for the message.
|
|
777
|
+
*/
|
|
778
|
+
malformedFilterInfo(kind, filterInfo, why) {
|
|
779
|
+
throw new QuereusError(`Malformed ${kind} FilterInfo (idxStr '${filterInfo.idxStr}') on ${this.schemaName}.${this.tableName}: ${why}`, StatusCode.INTERNAL);
|
|
780
|
+
}
|
|
572
781
|
/**
|
|
573
782
|
* Fail loudly on a multi-seek FilterInfo this table cannot serve. The plan already
|
|
574
783
|
* dropped the residual Filter, and {@link matchesFilters} ANDs every pushed
|
|
@@ -576,7 +785,7 @@ export class StoreTableScan extends StoreTableBase {
|
|
|
576
785
|
* equalities and return zero rows: a silent wrong answer, not a slow one.
|
|
577
786
|
*/
|
|
578
787
|
multiSeekMalformed(filterInfo, why) {
|
|
579
|
-
|
|
788
|
+
this.malformedFilterInfo('multi-seek', filterInfo, why);
|
|
580
789
|
}
|
|
581
790
|
/**
|
|
582
791
|
* Decode a multi-seek FilterInfo into its N seek tuples of width W. Tuple i pairs
|
|
@@ -645,16 +854,20 @@ export class StoreTableScan extends StoreTableBase {
|
|
|
645
854
|
* away before scanning:
|
|
646
855
|
* - Tuples whose encoded prefix byte-matches (duplicate bound parameters, or
|
|
647
856
|
* case variants under a NOCASE key collation C) share ONE window, each kept
|
|
648
|
-
* as a residual alternative — see {@link
|
|
857
|
+
* as a residual alternative — see {@link PendingIndexEntry}.
|
|
649
858
|
* - A window with no finite upper bound (all-0xff prefix — see
|
|
650
859
|
* buildIndexPrefixBounds) contains every later-sorting window outright (any
|
|
651
860
|
* key ≥ an all-0xff prefix necessarily starts with it), so those windows fold
|
|
652
861
|
* into it instead of re-scanning an overlapping range out of order.
|
|
653
862
|
*
|
|
654
|
-
* Emission is lazy
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
*
|
|
863
|
+
* Emission is lazy and batched ACROSS windows: every window's
|
|
864
|
+
* {@link produceIndexEntries} feeds ONE {@link resolveIndexEntries} consumer, whose
|
|
865
|
+
* batches are bounded by ENTRY count, not window count — so a hundred single-row
|
|
866
|
+
* windows collapse into a single row-resolution round trip, and
|
|
867
|
+
* `… in (…) limit 1` materializes at most one {@link ROW_RESOLUTION_BATCH} batch
|
|
868
|
+
* rather than every window. Each window's entries still come from
|
|
869
|
+
* {@link iterateEffective}, so read-your-own-writes and the stale-entry /
|
|
870
|
+
* deleted-row defenses hold per seek key.
|
|
658
871
|
*/
|
|
659
872
|
async *scanMultiSeek(spec, filterInfo) {
|
|
660
873
|
const { tuples, seekWidth } = this.decodeMultiSeekTuples(spec, filterInfo);
|
|
@@ -726,20 +939,45 @@ export class StoreTableScan extends StoreTableBase {
|
|
|
726
939
|
// Identical for every window (resolveFilterCollations dedups by column), so
|
|
727
940
|
// resolve once and thread it through.
|
|
728
941
|
const collations = this.resolveFilterCollations(filterInfo, this.indexColumnCollations(index));
|
|
942
|
+
// Data-key hexes already YIELDED, across ALL windows — the multi-seek dedup set
|
|
943
|
+
// (see produceIndexEntries / resolveRowBatch for the pre-check / add-on-yield
|
|
944
|
+
// split). NOTE: holds one hex string per row the multi-seek yields, for the
|
|
945
|
+
// whole seek — the only unbounded allocation on this path. Windows are
|
|
946
|
+
// byte-disjoint, so the only real duplicate source is a stale index entry; if a
|
|
947
|
+
// large-result `IN` ever shows up as a memory problem, the set can be scoped
|
|
948
|
+
// per window (or dropped for a consistent store).
|
|
729
949
|
const seen = new Set();
|
|
730
|
-
|
|
731
|
-
|
|
950
|
+
// ONE consumer over the concatenation of every window's producer: the windows
|
|
951
|
+
// are sorted disjoint by encoded key, so the concatenation is already the
|
|
952
|
+
// emission order, and bounding batches by entry count gives the cross-window
|
|
953
|
+
// collapse (a hundred single-row windows become one round trip).
|
|
954
|
+
yield* this.resolveIndexEntries(this.produceMultiSeekEntries(indexStore, disjoint, seen), collations, seen);
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Concatenate the disjoint multi-seek windows' producers, in window (byte) order.
|
|
958
|
+
* Each entry carries its window's full FilterInfo list — the primary tuple plus
|
|
959
|
+
* the byte-equal merged alternatives — so acceptance stays per row, in order (see
|
|
960
|
+
* {@link PendingIndexEntry}).
|
|
961
|
+
*/
|
|
962
|
+
async *produceMultiSeekEntries(indexStore, windows, seen) {
|
|
963
|
+
for (const w of windows) {
|
|
964
|
+
yield* this.produceIndexEntries(indexStore, w.bounds, w.infos, seen);
|
|
732
965
|
}
|
|
733
966
|
}
|
|
734
967
|
/**
|
|
735
968
|
* Multi-seek over the PRIMARY key: each tuple is a full PK, resolved by a point
|
|
736
969
|
* read in ascending encoded-data-key order (the table's native emission order).
|
|
737
970
|
*
|
|
738
|
-
*
|
|
739
|
-
*
|
|
740
|
-
*
|
|
741
|
-
*
|
|
742
|
-
*
|
|
971
|
+
* Reached from `computeBestAccessPlan`'s primary-key multi-seek arm
|
|
972
|
+
* (store-module-access-plan.ts), which claims a whole-primary-key `IN` — literal list
|
|
973
|
+
* or runtime-valued set — through `EQ_OR_IN_OPS` and advertises `_primary_` with every
|
|
974
|
+
* PK column as its seek columns. That arm declines exactly the two shapes the
|
|
975
|
+
* {@link multiSeekMalformed} throws below assert against (over the seek cap, and a
|
|
976
|
+
* semantic-ordering PK member), so a throw here means the plan and the scan disagreed.
|
|
977
|
+
*
|
|
978
|
+
* The ascending-encoded-key emission order is a contract, not an implementation
|
|
979
|
+
* detail: the isolation layer merges this stream with its overlay by primary key
|
|
980
|
+
* (`bug-isolation-multiseek-merge-order`), and the arm advertises PK ordering off it.
|
|
743
981
|
*/
|
|
744
982
|
async *scanMultiSeekPrimary(tuples, seekWidth, filterInfo) {
|
|
745
983
|
const pkColumns = this.tableSchema.primaryKeyDefinition.map(pk => pk.index);
|
|
@@ -772,10 +1010,20 @@ export class StoreTableScan extends StoreTableBase {
|
|
|
772
1010
|
points.set(hex, { key, infos: [info] });
|
|
773
1011
|
}
|
|
774
1012
|
const collations = this.resolveFilterCollations(filterInfo);
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
1013
|
+
const sorted = [...points.values()].sort((a, b) => compareBytes(a.key, b.key));
|
|
1014
|
+
// Bounded batches over the sorted, deduplicated key list — "fetch N rows by
|
|
1015
|
+
// key" is exactly the shape readEffectiveRowsByKeys batches (one round trip
|
|
1016
|
+
// per ROW_RESOLUTION_BATCH keys, pending overlay resolved first, positional
|
|
1017
|
+
// nulls for missing keys). Emission order is unchanged (ascending encoded data
|
|
1018
|
+
// key), and a downstream break mid-batch reads no further batch.
|
|
1019
|
+
for (let start = 0; start < sorted.length; start += ROW_RESOLUTION_BATCH) {
|
|
1020
|
+
const slice = sorted.slice(start, start + ROW_RESOLUTION_BATCH);
|
|
1021
|
+
const rows = await this.readEffectiveRowsByKeys(slice.map(p => p.key));
|
|
1022
|
+
for (let i = 0; i < slice.length; i++) {
|
|
1023
|
+
const row = rows[i];
|
|
1024
|
+
if (row && slice[i].infos.some(fi => this.matchesFilters(row, fi, collations))) {
|
|
1025
|
+
yield row;
|
|
1026
|
+
}
|
|
779
1027
|
}
|
|
780
1028
|
}
|
|
781
1029
|
}
|