@fjader/svelte 0.2.3 → 0.2.5
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 +3 -1
- package/dist/animation/EntranceGate.d.ts +52 -0
- package/dist/animation/EntranceGate.js +93 -0
- package/dist/animation/TransitionClock.d.ts +61 -30
- package/dist/animation/TransitionClock.js +67 -29
- package/dist/animation/transition-log.d.ts +41 -0
- package/dist/animation/transition-log.js +57 -0
- package/dist/components/axes/AxisBottom.svelte +27 -8
- package/dist/components/axes/AxisBottom.svelte.d.ts +10 -0
- package/dist/components/axes/AxisCenter.svelte +4 -2
- package/dist/components/axes/AxisLeft.svelte +81 -10
- package/dist/components/axes/AxisLeft.svelte.d.ts +17 -0
- package/dist/components/axes/BandAxisTicks.svelte +4 -3
- package/dist/components/axes/axis-title.d.ts +30 -0
- package/dist/components/axes/axis-title.js +77 -0
- package/dist/components/charts/ChartCell.svelte +33 -6
- package/dist/components/charts/ChartCell.svelte.d.ts +9 -0
- package/dist/components/charts/ChartContainer.svelte +55 -5
- package/dist/components/charts/ChartContainer.svelte.d.ts +14 -1
- package/dist/components/charts/ChartHover.svelte.d.ts +14 -0
- package/dist/components/charts/ChartHover.svelte.js +22 -0
- package/dist/components/charts/ChartVisibility.svelte.d.ts +43 -0
- package/dist/components/charts/ChartVisibility.svelte.js +67 -0
- package/dist/components/guides/Grid.svelte +12 -1
- package/dist/components/guides/legend/ContinuousColorLegend.svelte +7 -1
- package/dist/components/guides/legend/OrdinalColorLegend.svelte +8 -5
- package/dist/components/marks/AreaMarkX.svelte +18 -4
- package/dist/components/marks/AreaMarkY.svelte +18 -4
- package/dist/components/marks/BarMarkX.svelte +20 -4
- package/dist/components/marks/BarMarkY.svelte +20 -4
- package/dist/components/marks/DotsMark.svelte +8 -3
- package/dist/components/marks/LineMark.svelte +13 -6
- package/dist/components/marks/ParallelCoordinatesMark.svelte +11 -2
- package/dist/components/marks/ParallelCoordinatesMark.svelte.d.ts +4 -0
- package/dist/components/marks/RectMark.svelte +8 -3
- package/dist/components/marks/RectXMark.svelte +9 -3
- package/dist/components/marks/RectYMark.svelte +9 -3
- package/dist/components/marks/RuleMarkX.svelte +31 -25
- package/dist/components/marks/RuleMarkY.svelte +31 -25
- package/dist/components/marks/TextMark.svelte +52 -14
- package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.d.ts +22 -1
- package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.js +579 -66
- package/dist/components/marks/lifecycle/domain-queries.d.ts +13 -2
- package/dist/components/marks/lifecycle/domain-queries.js +43 -11
- package/dist/components/marks/lifecycle/domain-registration.d.ts +6 -1
- package/dist/components/marks/lifecycle/domain-registration.js +17 -20
- package/dist/components/marks/lifecycle/other-rows.svelte.d.ts +112 -0
- package/dist/components/marks/lifecycle/other-rows.svelte.js +193 -0
- package/dist/components/marks/lifecycle/other-split.d.ts +80 -0
- package/dist/components/marks/lifecycle/other-split.js +112 -0
- package/dist/components/marks/lifecycle/query-builder.d.ts +53 -3
- package/dist/components/marks/lifecycle/query-builder.js +46 -23
- package/dist/components/marks/utils/MarkColor.svelte.d.ts +3 -1
- package/dist/components/marks/utils/MarkColor.svelte.js +33 -3
- package/dist/components/marks/utils/MarkSeries.svelte.d.ts +14 -1
- package/dist/components/marks/utils/MarkSeries.svelte.js +25 -1
- package/dist/components/marks/utils/MarkSize.svelte.js +4 -0
- package/dist/components/marks/utils/reactive-config.d.ts +1 -1
- package/dist/components/marks/utils/reactive-config.js +9 -0
- package/dist/components/marks/utils/tooltip-helpers.d.ts +4 -1
- package/dist/components/marks/utils/tooltip-helpers.js +2 -1
- package/dist/components/rendering/Areas.svelte +57 -19
- package/dist/components/rendering/Areas.svelte.d.ts +12 -0
- package/dist/components/rendering/BaseGPURenderer.svelte.d.ts +3 -1
- package/dist/components/rendering/BaseGPURenderer.svelte.js +4 -2
- package/dist/components/rendering/Dots.svelte +23 -3
- package/dist/components/rendering/Dots.svelte.d.ts +6 -0
- package/dist/components/rendering/GridLine.svelte +13 -1
- package/dist/components/rendering/GridLine.svelte.d.ts +5 -0
- package/dist/components/rendering/Lines.svelte +48 -13
- package/dist/components/rendering/Lines.svelte.d.ts +12 -0
- package/dist/components/rendering/ParallelLines.svelte +5 -5
- package/dist/components/rendering/Rects.svelte +164 -30
- package/dist/components/rendering/Rects.svelte.d.ts +23 -2
- package/dist/components/rendering/RuleX.svelte +1 -1
- package/dist/components/rendering/RuleY.svelte +1 -1
- package/dist/components/rendering/WebGPUCanvas.svelte +4 -2
- package/dist/components/rendering/activity-reads.d.ts +47 -12
- package/dist/components/rendering/activity-reads.js +61 -19
- package/dist/components/rendering/uniform-sizes.d.ts +1 -1
- package/dist/components/rendering/uniform-sizes.js +1 -1
- package/dist/config/ChartConfig.svelte.d.ts +10 -1
- package/dist/config/ChartConfig.svelte.js +17 -0
- package/dist/gpu/GPUCoordinator.svelte.d.ts +31 -1
- package/dist/gpu/GPUCoordinator.svelte.js +164 -7
- package/dist/gpu/MorphedChannels.d.ts +11 -28
- package/dist/gpu/MorphedChannels.js +72 -8
- package/dist/gpu/ScaleTemplateSystem.d.ts +20 -2
- package/dist/gpu/ScaleTemplateSystem.js +83 -6
- package/dist/index.d.ts +19 -4
- package/dist/index.js +22 -4
- package/dist/interactors/Interval1D.svelte +6 -1
- package/dist/interactors/Interval2D.svelte +6 -1
- package/dist/interactors/ParallelAxisSource.svelte +10 -1
- package/dist/interactors/Toggle.svelte +32 -3
- package/dist/interactors/Tooltip.svelte +138 -41
- package/dist/interactors/TooltipManager.svelte.d.ts +20 -5
- package/dist/interactors/TooltipManager.svelte.js +46 -12
- package/dist/interactors/other-tooltip.d.ts +16 -0
- package/dist/interactors/other-tooltip.js +22 -0
- package/dist/query-engine/CrossfilterSelection.svelte.d.ts +4 -2
- package/dist/query-engine/CrossfilterSelection.svelte.js +2 -0
- package/dist/query-engine/FieldActivity.svelte.d.ts +49 -1
- package/dist/query-engine/FieldActivity.svelte.js +175 -5
- package/dist/query-engine/FilterGroup.svelte.d.ts +23 -1
- package/dist/query-engine/FilterGroup.svelte.js +43 -3
- package/dist/query-engine/FilterGroupRegistry.svelte.d.ts +31 -3
- package/dist/query-engine/FilterGroupRegistry.svelte.js +62 -15
- package/dist/query-engine/MarkHandle.svelte.d.ts +28 -1
- package/dist/query-engine/MarkHandle.svelte.js +33 -1
- package/dist/query-engine/QueryEngine.d.ts +225 -5
- package/dist/query-engine/QueryEngine.js +697 -47
- package/dist/query-engine/categorical-identity.d.ts +41 -0
- package/dist/query-engine/categorical-identity.js +145 -65
- package/dist/query-engine/debug/query-log.d.ts +39 -0
- package/dist/query-engine/debug/query-log.js +90 -0
- package/dist/query-engine/duckdb.d.ts +55 -8
- package/dist/query-engine/duckdb.js +82 -30
- package/dist/query-engine/index.d.ts +7 -4
- package/dist/query-engine/index.js +3 -3
- package/dist/query-engine/preagg/BuildQueue.d.ts +33 -13
- package/dist/query-engine/preagg/BuildQueue.js +51 -15
- package/dist/query-engine/preagg/Preagg.d.ts +46 -3
- package/dist/query-engine/preagg/Preagg.js +159 -31
- package/dist/query-engine/preagg/plan.d.ts +33 -1
- package/dist/query-engine/preagg/plan.js +158 -14
- package/dist/query-engine/sliced-lane.d.ts +108 -0
- package/dist/query-engine/sliced-lane.js +299 -0
- package/dist/query-engine/source-routing.d.ts +106 -0
- package/dist/query-engine/source-routing.js +189 -0
- package/dist/query-engine/toggle-predicate.d.ts +21 -0
- package/dist/query-engine/toggle-predicate.js +29 -1
- package/dist/scales/ActivityTable.d.ts +5 -1
- package/dist/scales/ActivityTable.js +12 -3
- package/dist/scales/BandScaleState.svelte.d.ts +22 -9
- package/dist/scales/BandScaleState.svelte.js +39 -21
- package/dist/scales/ChartScales.svelte.d.ts +3 -0
- package/dist/scales/ChartScales.svelte.js +26 -8
- package/dist/scales/ColorScaleState.svelte.d.ts +18 -2
- package/dist/scales/ColorScaleState.svelte.js +138 -26
- package/dist/scales/LinearScaleState.svelte.d.ts +7 -10
- package/dist/scales/LinearScaleState.svelte.js +21 -39
- package/dist/scales/ScaleGeneration.svelte.d.ts +48 -0
- package/dist/scales/ScaleGeneration.svelte.js +42 -0
- package/dist/scales/ScaleRegistry.svelte.js +3 -1
- package/dist/scales/SlotTransition.d.ts +15 -1
- package/dist/scales/SlotTransition.js +33 -4
- package/dist/scales/TimeScaleState.svelte.d.ts +7 -3
- package/dist/scales/TimeScaleState.svelte.js +7 -7
- package/dist/scales/color-utils.d.ts +0 -3
- package/dist/scales/color-utils.js +0 -3
- package/dist/scales/entered.d.ts +8 -0
- package/dist/scales/entered.js +10 -0
- package/dist/spec/LazyQueryEngine.js +2 -19
- package/dist/spec/MarkRegistry.d.ts +3 -1
- package/dist/spec/MarkRegistry.js +13 -1
- package/dist/spec/PlotFromSpec.svelte +1 -0
- package/dist/spec/export.js +11 -2
- package/dist/spec/types.d.ts +43 -13
- package/dist/sql/bin.d.ts +25 -0
- package/dist/sql/bin.js +108 -5
- package/dist/theme.css +5 -4
- package/dist/transforms/TransformCompiler.d.ts +47 -2
- package/dist/transforms/TransformCompiler.js +70 -1
- package/dist/transforms/index.js +176 -42
- package/dist/types.d.ts +177 -2
- package/package.json +4 -3
|
@@ -21,6 +21,8 @@ import { tableFromIPC } from '@uwdata/flechette';
|
|
|
21
21
|
import { assembleQuery, castNumeric, splitWindowSelect } from '../components/marks/lifecycle/query-builder';
|
|
22
22
|
import { Preagg } from './preagg/Preagg';
|
|
23
23
|
import { MarkHandle } from './MarkHandle.svelte';
|
|
24
|
+
import { LaneSlot } from './sliced-lane';
|
|
25
|
+
import { SourceRouting, sourceDDL } from './source-routing';
|
|
24
26
|
import { CategoricalIdentity } from './categorical-identity';
|
|
25
27
|
import { NO_ROUTE } from './CrossfilterSelection.svelte';
|
|
26
28
|
/**
|
|
@@ -31,6 +33,33 @@ import { NO_ROUTE } from './CrossfilterSelection.svelte';
|
|
|
31
33
|
* build instead.
|
|
32
34
|
*/
|
|
33
35
|
const BUILD_MAX_WAIT_MS = 100;
|
|
36
|
+
/**
|
|
37
|
+
* Sliced lanes for interaction reads of unknown or slow sources. A source
|
|
38
|
+
* sticks to one; more sources than lanes share them (reads of one source are
|
|
39
|
+
* serial anyway).
|
|
40
|
+
*/
|
|
41
|
+
const SOURCE_LANES = 4;
|
|
42
|
+
/**
|
|
43
|
+
* How long the main connection must stay idle before a lane runs its next
|
|
44
|
+
* slice. Longer than the JS gap between two statements of a dependent chain
|
|
45
|
+
* (one settles, the next is built and sent), so the chain runs unbroken
|
|
46
|
+
* instead of letting one slice in at every gap.
|
|
47
|
+
*/
|
|
48
|
+
const MAIN_QUIET_MS = 2;
|
|
49
|
+
/**
|
|
50
|
+
* While the main connection works, all lanes together get one slice per this
|
|
51
|
+
* many ms: half the worker at 5 ms slices. A slow source keeps a minimum
|
|
52
|
+
* rate, and a fast query waits at most one slice for it. One budget for
|
|
53
|
+
* every lane, the background lane too: a budget per lane would let five busy
|
|
54
|
+
* lanes take the whole worker between them.
|
|
55
|
+
*
|
|
56
|
+
* Measured beside a slow chart during a brush (/test/query-load?slow=1,
|
|
57
|
+
* scripted drag, two runs each): the fast charts' p90 stayed at ~20 ms and
|
|
58
|
+
* their updates the same at 25, 15 and 10 ms (their cost follows the slow
|
|
59
|
+
* chart's large results decoding, not the share), while the slow chart's
|
|
60
|
+
* data age fell from ~500 to ~375 to ~345 ms median.
|
|
61
|
+
*/
|
|
62
|
+
const SLICE_SHARE_MS = 10;
|
|
34
63
|
// DESCRIBE reports canonical type names, so these match exactly. Anything else (VARCHAR,
|
|
35
64
|
// ENUM, lists, STRUCT, INTERVAL, extension types) is a string, which is never cast to REAL.
|
|
36
65
|
const NUMBER_TYPES = new Set([
|
|
@@ -85,11 +114,41 @@ export function buildSchemaMap(columns) {
|
|
|
85
114
|
// ---------------------------------------------------------------------------
|
|
86
115
|
// QueryEngine
|
|
87
116
|
// ---------------------------------------------------------------------------
|
|
117
|
+
/**
|
|
118
|
+
* How a mark's data read is placed. Routed by the table it reads
|
|
119
|
+
* (SourceRouting): the preagg table when one answers, else the mark's BASE
|
|
120
|
+
* table (its identity view reads the same rows, and its range and stats reads
|
|
121
|
+
* name the base table: one record per source). `slow` (holdsEntrance={false})
|
|
122
|
+
* applies to the base query only: a preagg read is ~1 ms, and forced onto a
|
|
123
|
+
* lane during a brush it would cost ~20 ms of round trips in exactly the case
|
|
124
|
+
* preagg exists to make fast.
|
|
125
|
+
*/
|
|
126
|
+
export function markReadOptions(markId, descriptor, preaggTable) {
|
|
127
|
+
return {
|
|
128
|
+
callerId: markId,
|
|
129
|
+
source: preaggTable ?? descriptor.baseTable,
|
|
130
|
+
data: true,
|
|
131
|
+
slow: !preaggTable && descriptor.slow
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* DuckDB's catalog error for a table or view this connection cannot see.
|
|
136
|
+
* Matched by its wording, which an upgrade could change: the integration
|
|
137
|
+
* test missing-relation.integration.test.ts checks it against real DuckDB.
|
|
138
|
+
*/
|
|
139
|
+
export function isMissingRelation(e) {
|
|
140
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
141
|
+
return /Catalog Error: (Table|View) with name .+ does not exist/i.test(message);
|
|
142
|
+
}
|
|
88
143
|
export class QueryEngine {
|
|
89
144
|
#conn = null;
|
|
90
145
|
#connPromise = null;
|
|
91
146
|
#inflight = new Map();
|
|
92
147
|
#pending = new Map();
|
|
148
|
+
/** Interaction statements handed to the connection and not yet settled (see #send). */
|
|
149
|
+
#interactionInFlight = 0;
|
|
150
|
+
/** Debug listeners for every statement sent (observeStatements). */
|
|
151
|
+
#statementObservers = new Set();
|
|
93
152
|
#describeCache = new Map();
|
|
94
153
|
#marks = new Map();
|
|
95
154
|
#destroyed = false;
|
|
@@ -97,7 +156,15 @@ export class QueryEngine {
|
|
|
97
156
|
#preagg;
|
|
98
157
|
/** When a filter last changed, in any group (performance.now()). */
|
|
99
158
|
#lastFilterChangeAt = 0;
|
|
100
|
-
/**
|
|
159
|
+
/**
|
|
160
|
+
* LRU query cache: Arrow Table (or Promise<Table> while inflight), keyed by
|
|
161
|
+
* `#cacheKey`, the SQL text and the dims' version. A mark's query reads dim
|
|
162
|
+
* tables (dense padding pads every category the dim holds), and dims grow
|
|
163
|
+
* after the first queries run: a table folding in, a topN mark reserving its
|
|
164
|
+
* Other. The text does not change when they do, so keyed by text alone the
|
|
165
|
+
* cache served a result from before, with fewer rows: the row set changed
|
|
166
|
+
* between updates and the bars snapped instead of morphing.
|
|
167
|
+
*/
|
|
101
168
|
#queryCache = lruCache({ max: 1000, ttl: 3 * 60 * 60 * 1000 });
|
|
102
169
|
/**
|
|
103
170
|
* Per-instance suffix for every table and view this engine creates.
|
|
@@ -118,19 +185,44 @@ export class QueryEngine {
|
|
|
118
185
|
rows.push(table.get(i));
|
|
119
186
|
}
|
|
120
187
|
return rows;
|
|
188
|
+
},
|
|
189
|
+
// Seen created as a table through the engine, or listed in the catalog
|
|
190
|
+
// (which lists tables, not views).
|
|
191
|
+
isBaseTable: (table) => this.#known.get(table) === 'table' || !!this.#catalog?.has(table),
|
|
192
|
+
observing: () => this.#identityObservers.size > 0,
|
|
193
|
+
onRequest: (table, fields, time) => {
|
|
194
|
+
this.#notifyIdentity({ kind: 'request', table, fields, time });
|
|
195
|
+
},
|
|
196
|
+
onDrain: (table, fields, time) => {
|
|
197
|
+
this.#notifyIdentity({ kind: 'drain', table, fields, time });
|
|
198
|
+
},
|
|
199
|
+
execBatch: async (statements) => {
|
|
200
|
+
const table = await this.execBatch(statements);
|
|
201
|
+
const rows = [];
|
|
202
|
+
for (let i = 0; i < table.numRows; i++) {
|
|
203
|
+
rows.push(table.get(i));
|
|
204
|
+
}
|
|
205
|
+
return rows;
|
|
121
206
|
}
|
|
122
207
|
}, this.#session);
|
|
123
208
|
/** Notified after clearQueryCache() drops the identity dims and views. */
|
|
124
209
|
#identityResetListeners = new Set();
|
|
125
210
|
constructor(connection, options = {}) {
|
|
126
211
|
this.#preagg = new Preagg({
|
|
127
|
-
exec: (sql) => this.exec(sql),
|
|
128
|
-
query: (sql, opts) => this.query(sql, opts),
|
|
129
|
-
|
|
212
|
+
exec: (sql, signal) => this.exec(sql, { class: 'background', signal }),
|
|
213
|
+
query: (sql, opts) => this.query(sql, { class: 'background', ...opts }),
|
|
214
|
+
// A statement someone waits for is on the connection, or a mark
|
|
215
|
+
// query is waiting to be sent.
|
|
216
|
+
busy: () => this.#interactionInFlight > 0 || this.#pending.size > 0,
|
|
217
|
+
sliced: () => this.#slicedReady(),
|
|
130
218
|
identity: this.#identity,
|
|
131
219
|
mark: (markId) => this.#marks.get(markId),
|
|
132
|
-
marks: () => this.#marks
|
|
220
|
+
marks: () => this.#marks,
|
|
221
|
+
observingBuilds: () => this.#buildObservers.size > 0,
|
|
222
|
+
onBuild: (event) => this.#notifyBuild(event)
|
|
133
223
|
}, options.buildMaxWaitMs ?? BUILD_MAX_WAIT_MS);
|
|
224
|
+
this.#sliceShareMs = options.sliceShareMs ?? SLICE_SHARE_MS;
|
|
225
|
+
this.#unbatch = options.unbatch ?? false;
|
|
134
226
|
if (connection instanceof Promise) {
|
|
135
227
|
this.#connPromise = connection;
|
|
136
228
|
connection.then((c) => {
|
|
@@ -285,18 +377,316 @@ export class QueryEngine {
|
|
|
285
377
|
}
|
|
286
378
|
}
|
|
287
379
|
}
|
|
380
|
+
/**
|
|
381
|
+
* The one place a statement reaches the connection. Everything that talks to
|
|
382
|
+
* DuckDB goes through here: queries, statements, DESCRIBE, Arrow inserts.
|
|
383
|
+
*
|
|
384
|
+
* What attaches here: the interaction count builds yield to, and statement
|
|
385
|
+
* observers (debugging, measurement). Later: routing a class to a connection,
|
|
386
|
+
* and per-connection queues.
|
|
387
|
+
*/
|
|
388
|
+
async #send(kind, sql, place, how) {
|
|
389
|
+
const { run, runSliced, signal } = how;
|
|
390
|
+
if (!this.#conn)
|
|
391
|
+
await this.#ensureConnection();
|
|
392
|
+
const cls = place.class;
|
|
393
|
+
// Awaited only while a lane is opening (LaneSlot.get): once its state is
|
|
394
|
+
// known the choice is made in this tick.
|
|
395
|
+
const slot = runSliced ? this.#slotFor(place) : null;
|
|
396
|
+
const got = slot ? slot.get() : null;
|
|
397
|
+
const lane = got instanceof Promise ? await got : got;
|
|
398
|
+
// The main connection cannot interrupt a statement once sent: a signal
|
|
399
|
+
// only stops one that has not gone yet. A lane cancels (SlicedLane).
|
|
400
|
+
if (!lane && signal?.aborted)
|
|
401
|
+
throw new DOMException('Statement aborted', 'AbortError');
|
|
402
|
+
const conn = this.#conn;
|
|
403
|
+
const interaction = cls === 'interaction';
|
|
404
|
+
if (interaction)
|
|
405
|
+
this.#interactionInFlight++;
|
|
406
|
+
const start = performance.now();
|
|
407
|
+
let error;
|
|
408
|
+
// Paired with #mainIdle in the finally below.
|
|
409
|
+
if (!lane)
|
|
410
|
+
this.#mainBusy();
|
|
411
|
+
try {
|
|
412
|
+
return lane ? await runSliced(lane) : await run(conn);
|
|
413
|
+
}
|
|
414
|
+
catch (e) {
|
|
415
|
+
error = e;
|
|
416
|
+
throw e;
|
|
417
|
+
}
|
|
418
|
+
finally {
|
|
419
|
+
if (this.#statementObservers.size > 0) {
|
|
420
|
+
const record = {
|
|
421
|
+
sql,
|
|
422
|
+
kind,
|
|
423
|
+
class: cls,
|
|
424
|
+
connection: lane ? 'lane' : 'main',
|
|
425
|
+
count: how.count,
|
|
426
|
+
source: place.source,
|
|
427
|
+
requested: place.requestedAt,
|
|
428
|
+
caller: place.callerId,
|
|
429
|
+
start,
|
|
430
|
+
end: performance.now(),
|
|
431
|
+
error
|
|
432
|
+
};
|
|
433
|
+
// A listener is for debugging: one that throws must not fail the
|
|
434
|
+
// statement, nor skip the in-flight count below.
|
|
435
|
+
for (const observe of this.#statementObservers) {
|
|
436
|
+
try {
|
|
437
|
+
observe(record);
|
|
438
|
+
}
|
|
439
|
+
catch (e) {
|
|
440
|
+
console.error('[QueryEngine] statement observer threw:', e);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (!lane)
|
|
445
|
+
this.#mainIdle();
|
|
446
|
+
// The last interaction statement settling frees the connection for a build.
|
|
447
|
+
if (interaction && --this.#interactionInFlight === 0)
|
|
448
|
+
this.#preagg.pump();
|
|
449
|
+
}
|
|
450
|
+
}
|
|
288
451
|
/**
|
|
289
452
|
* Execute a SQL query and return a flechette Table.
|
|
290
453
|
* Uses the fast IPC path (queryIPC → tableFromIPC) when available,
|
|
291
454
|
* falling back to the standard conn.query() for mocks/testing.
|
|
292
455
|
*/
|
|
293
|
-
|
|
456
|
+
#queryRaw(sql, place, kind = 'query', signal) {
|
|
457
|
+
let sliced = false;
|
|
458
|
+
const read = this.#send(kind, sql, place, {
|
|
459
|
+
run: async (conn) => {
|
|
460
|
+
if (!conn.queryIPC)
|
|
461
|
+
return conn.query(sql);
|
|
462
|
+
const t0 = performance.now();
|
|
463
|
+
const bytes = await conn.queryIPC(sql);
|
|
464
|
+
const ms = performance.now() - t0;
|
|
465
|
+
const table = tableFromIPC(bytes);
|
|
466
|
+
if (this.#notesCost(place))
|
|
467
|
+
this.#routing.noteMainRead(place.source, ms, table.numRows);
|
|
468
|
+
return table;
|
|
469
|
+
},
|
|
470
|
+
runSliced: async (lane) => {
|
|
471
|
+
sliced = true;
|
|
472
|
+
const result = await lane.query(sql, signal);
|
|
473
|
+
const table = tableFromIPC(result.ipc);
|
|
474
|
+
if (this.#notesCost(place)) {
|
|
475
|
+
this.#routing.noteLaneRead(place.source, result.polls, result.execMs, table.numRows);
|
|
476
|
+
}
|
|
477
|
+
return table;
|
|
478
|
+
},
|
|
479
|
+
signal
|
|
480
|
+
});
|
|
481
|
+
// Safety net for a TEMP source made where the engine could not see it
|
|
482
|
+
// (on the app's connection, which is the main one): a lane does not
|
|
483
|
+
// have it. Once found missing there, it stays on the main connection
|
|
484
|
+
// for good, and this read is sent there. Local is the safe direction:
|
|
485
|
+
// a race (a lane read during a drop and re-create made outside the
|
|
486
|
+
// engine) can only make a source less sliced, which is how every
|
|
487
|
+
// source read before lanes.
|
|
488
|
+
return read.catch((e) => {
|
|
489
|
+
const source = place.source;
|
|
490
|
+
if (!sliced || !source || !isMissingRelation(e))
|
|
491
|
+
throw e;
|
|
492
|
+
this.#localSources.add(source);
|
|
493
|
+
return this.#queryRaw(sql, place, kind, signal);
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* The lane a statement runs on, or null for the main connection:
|
|
498
|
+
* - connection-local state, or a statement with no sliced form: main;
|
|
499
|
+
* - background: the background lane (preagg builds and their reads);
|
|
500
|
+
* - interaction naming a source: by the source's cost (SourceRouting),
|
|
501
|
+
* unknown or slow on that source's lane, known-fast on main.
|
|
502
|
+
* Without the capability every slot answers null: one connection.
|
|
503
|
+
*/
|
|
504
|
+
#slotFor(place) {
|
|
505
|
+
if (place.connectionLocal)
|
|
506
|
+
return null;
|
|
507
|
+
if (place.class === 'background')
|
|
508
|
+
return this.#backgroundLane;
|
|
509
|
+
if (!place.source || !this.#conn?.openSlicedLane)
|
|
510
|
+
return null;
|
|
511
|
+
if (this.#localSources.has(place.source))
|
|
512
|
+
return null;
|
|
513
|
+
const i = place.slow ? this.#routing.laneOf(place.source) : this.#routing.route(place.source);
|
|
514
|
+
return i == null ? null : this.#sourceLanes[i];
|
|
515
|
+
}
|
|
516
|
+
/** Whether a read's cost goes into its source's record: interaction data reads. */
|
|
517
|
+
#notesCost(place) {
|
|
518
|
+
// A read known to be slow says nothing about its source: a slow mark's
|
|
519
|
+
// heavy query would otherwise turn a fast table slow for every mark.
|
|
520
|
+
return place.class === 'interaction' && !!place.data && !!place.source && !place.slow;
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Whether background statements run sliced, on their own connection, so
|
|
524
|
+
* they no longer hold up interaction. Asks for the lane, so the first ask
|
|
525
|
+
* opens it; false until it is open.
|
|
526
|
+
*/
|
|
527
|
+
#slicedReady() {
|
|
528
|
+
this.#backgroundLane.get();
|
|
529
|
+
return this.#backgroundLane.isOpen;
|
|
530
|
+
}
|
|
531
|
+
#openLaneFn = () => {
|
|
294
532
|
const conn = this.#conn;
|
|
295
|
-
if (conn
|
|
296
|
-
|
|
297
|
-
|
|
533
|
+
if (!conn?.openSlicedLane || this.#destroyed)
|
|
534
|
+
return null;
|
|
535
|
+
return conn.openSlicedLane({ hold: this.#sliceGrant });
|
|
536
|
+
};
|
|
537
|
+
// --- Holding lanes while the main connection works ---
|
|
538
|
+
//
|
|
539
|
+
// A lane's slice is a message at the back of the worker's queue, so it
|
|
540
|
+
// runs between two main-connection statements; in a dependent chain (a
|
|
541
|
+
// mark's setup: dims, views, then data) every step then waits a slice.
|
|
542
|
+
// So while the main connection works, lanes share a budget of slices
|
|
543
|
+
// (SLICE_SHARE_MS, across all of them); once it has been idle for
|
|
544
|
+
// MAIN_QUIET_MS they run freely. Counted here, per connection: a slow
|
|
545
|
+
// source's reads are interaction class too, and a lane waiting on
|
|
546
|
+
// #interactionInFlight would wait for itself.
|
|
547
|
+
/** Statements on the main connection, sent and not settled. */
|
|
548
|
+
#mainInFlight = 0;
|
|
549
|
+
/** When the main connection last went idle. */
|
|
550
|
+
#mainIdleAt = 0;
|
|
551
|
+
#sliceShareMs;
|
|
552
|
+
#unbatch;
|
|
553
|
+
/** When any lane last took a slice from the share. */
|
|
554
|
+
#lastSliceAt = -Infinity;
|
|
555
|
+
/** Lanes waiting for a slice, first come first served. */
|
|
556
|
+
#sliceWaiters = [];
|
|
557
|
+
#shareTimer = null;
|
|
558
|
+
#quietTimer = null;
|
|
559
|
+
#mainBusy() {
|
|
560
|
+
this.#mainInFlight++;
|
|
561
|
+
if (this.#quietTimer) {
|
|
562
|
+
clearTimeout(this.#quietTimer);
|
|
563
|
+
this.#quietTimer = null;
|
|
298
564
|
}
|
|
299
|
-
|
|
565
|
+
}
|
|
566
|
+
#mainIdle() {
|
|
567
|
+
if (--this.#mainInFlight > 0)
|
|
568
|
+
return;
|
|
569
|
+
this.#mainIdleAt = performance.now();
|
|
570
|
+
if (this.#sliceWaiters.length > 0)
|
|
571
|
+
this.#armQuiet();
|
|
572
|
+
}
|
|
573
|
+
#mainQuietNow() {
|
|
574
|
+
return this.#mainInFlight === 0 && performance.now() - this.#mainIdleAt >= MAIN_QUIET_MS;
|
|
575
|
+
}
|
|
576
|
+
/** Once the main connection has stayed idle long enough, every waiting lane goes. */
|
|
577
|
+
#armQuiet() {
|
|
578
|
+
if (this.#quietTimer)
|
|
579
|
+
clearTimeout(this.#quietTimer);
|
|
580
|
+
const left = MAIN_QUIET_MS - (performance.now() - this.#mainIdleAt);
|
|
581
|
+
this.#quietTimer = setTimeout(() => {
|
|
582
|
+
this.#quietTimer = null;
|
|
583
|
+
if (this.#mainInFlight > 0)
|
|
584
|
+
return;
|
|
585
|
+
const waiting = this.#sliceWaiters;
|
|
586
|
+
this.#sliceWaiters = [];
|
|
587
|
+
for (const go of waiting)
|
|
588
|
+
go();
|
|
589
|
+
}, Math.max(0, left));
|
|
590
|
+
}
|
|
591
|
+
/** The next waiting lane gets the share's next slice. */
|
|
592
|
+
#armShare() {
|
|
593
|
+
if (this.#shareTimer)
|
|
594
|
+
return;
|
|
595
|
+
const left = this.#lastSliceAt + this.#sliceShareMs - performance.now();
|
|
596
|
+
this.#shareTimer = setTimeout(() => {
|
|
597
|
+
this.#shareTimer = null;
|
|
598
|
+
const go = this.#sliceWaiters.shift();
|
|
599
|
+
if (!go)
|
|
600
|
+
return;
|
|
601
|
+
this.#lastSliceAt = performance.now();
|
|
602
|
+
go();
|
|
603
|
+
if (this.#sliceWaiters.length > 0)
|
|
604
|
+
this.#armShare();
|
|
605
|
+
}, Math.max(0, left));
|
|
606
|
+
}
|
|
607
|
+
/** LaneHold: may a lane run its next slice now? */
|
|
608
|
+
#sliceGrant = () => {
|
|
609
|
+
if (this.#mainQuietNow())
|
|
610
|
+
return null;
|
|
611
|
+
const now = performance.now();
|
|
612
|
+
if (this.#sliceWaiters.length === 0 && now - this.#lastSliceAt >= this.#sliceShareMs) {
|
|
613
|
+
this.#lastSliceAt = now;
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
return new Promise((resolve) => {
|
|
617
|
+
this.#sliceWaiters.push(resolve);
|
|
618
|
+
this.#armShare();
|
|
619
|
+
if (this.#mainInFlight === 0)
|
|
620
|
+
this.#armQuiet();
|
|
621
|
+
});
|
|
622
|
+
};
|
|
623
|
+
#laneFailed = (e) => {
|
|
624
|
+
if (this.#warnedLaneFailed)
|
|
625
|
+
return;
|
|
626
|
+
this.#warnedLaneFailed = true;
|
|
627
|
+
console.warn('[QueryEngine] could not open a sliced lane; its statements stay on the main connection:', e);
|
|
628
|
+
};
|
|
629
|
+
#warnedLaneFailed = false;
|
|
630
|
+
/** Background statements: preagg builds, their counts and drops. */
|
|
631
|
+
#backgroundLane = new LaneSlot(this.#openLaneFn, this.#laneFailed);
|
|
632
|
+
/** Interaction reads of unknown and slow sources, a source sticking to one. */
|
|
633
|
+
#sourceLanes = Array.from({ length: SOURCE_LANES }, () => new LaneSlot(this.#openLaneFn, this.#laneFailed));
|
|
634
|
+
#routing = new SourceRouting(SOURCE_LANES);
|
|
635
|
+
/**
|
|
636
|
+
* Debug: every categorical identity request (a mark's ensureColumns call),
|
|
637
|
+
* with its table, fields and time, before deduplication, and every drain
|
|
638
|
+
* start (a table's dims beginning to build, with the fields it found
|
|
639
|
+
* pending). Returns the unsubscribe; costs nothing while no one listens, and
|
|
640
|
+
* a listener that throws is logged without failing identity work.
|
|
641
|
+
*/
|
|
642
|
+
observeIdentityRequests(listener) {
|
|
643
|
+
this.#identityObservers.add(listener);
|
|
644
|
+
return () => this.#identityObservers.delete(listener);
|
|
645
|
+
}
|
|
646
|
+
#identityObservers = new Set();
|
|
647
|
+
/**
|
|
648
|
+
* Debug: every step of every preagg build (BuildEvent): planned or
|
|
649
|
+
* skipped (with the size guard's numbers), queued (with its hover batch),
|
|
650
|
+
* started, then built, failed or dropped (with the check that failed and
|
|
651
|
+
* the values it compared). Returns the unsubscribe; costs nothing while no
|
|
652
|
+
* one listens, and a listener that throws is logged without failing the
|
|
653
|
+
* build.
|
|
654
|
+
*/
|
|
655
|
+
observeBuilds(listener) {
|
|
656
|
+
this.#buildObservers.add(listener);
|
|
657
|
+
return () => this.#buildObservers.delete(listener);
|
|
658
|
+
}
|
|
659
|
+
#buildObservers = new Set();
|
|
660
|
+
/** Each listener in its own try/catch: a debugging hook must not fail a build. */
|
|
661
|
+
#notifyBuild(event) {
|
|
662
|
+
for (const observe of this.#buildObservers) {
|
|
663
|
+
try {
|
|
664
|
+
observe(event);
|
|
665
|
+
}
|
|
666
|
+
catch (e) {
|
|
667
|
+
console.error('[QueryEngine] build observer threw:', e);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
/** Each listener in its own try/catch: a debugging hook must not fail identity work. */
|
|
672
|
+
#notifyIdentity(request) {
|
|
673
|
+
for (const observe of this.#identityObservers) {
|
|
674
|
+
try {
|
|
675
|
+
observe(request);
|
|
676
|
+
}
|
|
677
|
+
catch (e) {
|
|
678
|
+
console.error('[QueryEngine] identity observer threw:', e);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Listen to every statement sent to the connection, as it settles. For
|
|
684
|
+
* debugging and measurement; returns the unsubscribe. Costs nothing while
|
|
685
|
+
* no one listens.
|
|
686
|
+
*/
|
|
687
|
+
observeStatements(listener) {
|
|
688
|
+
this.#statementObservers.add(listener);
|
|
689
|
+
return () => this.#statementObservers.delete(listener);
|
|
300
690
|
}
|
|
301
691
|
/** Resolve #conn from #connPromise if needed. No-op when already resolved. */
|
|
302
692
|
#ensureConnection() {
|
|
@@ -324,12 +714,21 @@ export class QueryEngine {
|
|
|
324
714
|
if (!this.#conn)
|
|
325
715
|
await this.#ensureConnection();
|
|
326
716
|
const sqlStr = typeof sql === 'string' ? sql : sql.toString();
|
|
717
|
+
const place = {
|
|
718
|
+
class: opts?.class ?? 'interaction',
|
|
719
|
+
connectionLocal: opts?.connectionLocal,
|
|
720
|
+
source: opts?.source,
|
|
721
|
+
data: opts?.data,
|
|
722
|
+
requestedAt: performance.now(),
|
|
723
|
+
callerId: opts?.callerId,
|
|
724
|
+
slow: opts?.slow
|
|
725
|
+
};
|
|
327
726
|
// Cache check: return cached result immediately, bypassing
|
|
328
727
|
// inflight/pending bookkeeping entirely.
|
|
329
728
|
// Opt out with cache: false for queries against volatile temp tables.
|
|
330
729
|
const useCache = opts?.cache !== false;
|
|
331
730
|
if (useCache) {
|
|
332
|
-
const cached = this.#queryCache.get(sqlStr);
|
|
731
|
+
const cached = this.#queryCache.get(this.#cacheKey(sqlStr));
|
|
333
732
|
if (cached !== undefined) {
|
|
334
733
|
return cached instanceof Promise ? cached : Promise.resolve(cached);
|
|
335
734
|
}
|
|
@@ -348,27 +747,66 @@ export class QueryEngine {
|
|
|
348
747
|
}
|
|
349
748
|
// Store as pending — will be executed when inflight completes
|
|
350
749
|
return new Promise((resolve, reject) => {
|
|
351
|
-
this.#pending.set(callerId, {
|
|
750
|
+
this.#pending.set(callerId, {
|
|
751
|
+
sql: sqlStr,
|
|
752
|
+
resolve,
|
|
753
|
+
reject,
|
|
754
|
+
cache: useCache,
|
|
755
|
+
place
|
|
756
|
+
});
|
|
352
757
|
});
|
|
353
758
|
}
|
|
354
|
-
return this.#executeForCaller(callerId, sqlStr, opts.signal, useCache);
|
|
759
|
+
return this.#executeForCaller(callerId, sqlStr, opts.signal, useCache, place);
|
|
760
|
+
}
|
|
761
|
+
// No callerId, cache asked for: the first caller's promise goes in the
|
|
762
|
+
// cache, so a concurrent identical query (two marks asking the same
|
|
763
|
+
// range) shares it, and a later one reads the result. A cached hit
|
|
764
|
+
// already returned above.
|
|
765
|
+
if (opts?.cache === true) {
|
|
766
|
+
if (opts.signal?.aborted)
|
|
767
|
+
throw new DOMException('Query aborted', 'AbortError');
|
|
768
|
+
const key = this.#cacheKey(sqlStr);
|
|
769
|
+
const promise = this.#queryRaw(sqlStr, place, 'query', opts.signal);
|
|
770
|
+
this.#queryCache.set(key, promise);
|
|
771
|
+
let result;
|
|
772
|
+
try {
|
|
773
|
+
result = await promise;
|
|
774
|
+
this.#queryCache.set(key, result);
|
|
775
|
+
}
|
|
776
|
+
catch (e) {
|
|
777
|
+
this.#queryCache.delete(key);
|
|
778
|
+
throw e;
|
|
779
|
+
}
|
|
780
|
+
// Aborted while running: the result stays cached for others (it is
|
|
781
|
+
// correct), but this caller asked not to receive it.
|
|
782
|
+
if (opts.signal?.aborted)
|
|
783
|
+
throw new DOMException('Query aborted', 'AbortError');
|
|
784
|
+
return result;
|
|
355
785
|
}
|
|
356
786
|
// No callerId: direct execution, still respect external signal
|
|
357
787
|
if (opts?.signal) {
|
|
358
788
|
if (opts.signal.aborted) {
|
|
359
789
|
throw new DOMException('Query aborted', 'AbortError');
|
|
360
790
|
}
|
|
361
|
-
const result = await this.#queryRaw(sqlStr);
|
|
791
|
+
const result = await this.#queryRaw(sqlStr, place, 'query', opts.signal);
|
|
362
792
|
if (opts.signal.aborted) {
|
|
363
793
|
throw new DOMException('Query aborted', 'AbortError');
|
|
364
794
|
}
|
|
365
795
|
return result;
|
|
366
796
|
}
|
|
367
|
-
return this.#queryRaw(sqlStr);
|
|
797
|
+
return this.#queryRaw(sqlStr, place);
|
|
368
798
|
}
|
|
369
|
-
|
|
799
|
+
/**
|
|
800
|
+
* The cache key for a query: its text and the dims' version, so a result read
|
|
801
|
+
* against a dim that has since grown is not served again.
|
|
802
|
+
*/
|
|
803
|
+
#cacheKey(sqlStr) {
|
|
804
|
+
return `${this.#identity.version}\u0000${sqlStr}`;
|
|
805
|
+
}
|
|
806
|
+
async #executeForCaller(callerId, sqlStr, signal, cache = true, place = { class: 'interaction' }) {
|
|
370
807
|
const controller = new AbortController();
|
|
371
808
|
this.#inflight.set(callerId, controller);
|
|
809
|
+
let key = null;
|
|
372
810
|
if (signal) {
|
|
373
811
|
signal.addEventListener('abort', () => controller.abort(), { once: true });
|
|
374
812
|
}
|
|
@@ -376,24 +814,28 @@ export class QueryEngine {
|
|
|
376
814
|
if (controller.signal.aborted) {
|
|
377
815
|
throw new DOMException('Query superseded', 'AbortError');
|
|
378
816
|
}
|
|
817
|
+
// Keyed when it runs, not when it was asked for: a pending query runs
|
|
818
|
+
// later, against whatever the dims hold by then.
|
|
819
|
+
key = this.#cacheKey(sqlStr);
|
|
379
820
|
// Cache the Promise so concurrent identical queries share one inflight
|
|
380
|
-
const queryPromise = this.#queryRaw(sqlStr);
|
|
821
|
+
const queryPromise = this.#queryRaw(sqlStr, place, 'query', controller.signal);
|
|
381
822
|
if (cache)
|
|
382
|
-
this.#queryCache.set(
|
|
823
|
+
this.#queryCache.set(key, queryPromise);
|
|
383
824
|
const result = await queryPromise;
|
|
384
825
|
// Replace Promise with resolved data for future lookups
|
|
385
826
|
if (cache)
|
|
386
|
-
this.#queryCache.set(
|
|
827
|
+
this.#queryCache.set(key, result);
|
|
387
828
|
if (controller.signal.aborted) {
|
|
388
829
|
// Don't cache results from aborted queries
|
|
389
|
-
this.#queryCache.delete(
|
|
830
|
+
this.#queryCache.delete(key);
|
|
390
831
|
throw new DOMException('Query superseded', 'AbortError');
|
|
391
832
|
}
|
|
392
833
|
return result;
|
|
393
834
|
}
|
|
394
835
|
catch (e) {
|
|
395
836
|
// Remove failed query from cache so we don't serve a rejected Promise
|
|
396
|
-
|
|
837
|
+
if (key !== null)
|
|
838
|
+
this.#queryCache.delete(key);
|
|
397
839
|
throw e;
|
|
398
840
|
}
|
|
399
841
|
finally {
|
|
@@ -406,7 +848,7 @@ export class QueryEngine {
|
|
|
406
848
|
const pending = this.#pending.get(callerId);
|
|
407
849
|
if (pending) {
|
|
408
850
|
this.#pending.delete(callerId);
|
|
409
|
-
this.#executeForCaller(callerId, pending.sql, undefined, pending.cache)
|
|
851
|
+
this.#executeForCaller(callerId, pending.sql, undefined, pending.cache, pending.place)
|
|
410
852
|
.then(pending.resolve)
|
|
411
853
|
.catch(pending.reject);
|
|
412
854
|
}
|
|
@@ -414,28 +856,117 @@ export class QueryEngine {
|
|
|
414
856
|
this.#preagg.pump();
|
|
415
857
|
}
|
|
416
858
|
}
|
|
859
|
+
/**
|
|
860
|
+
* Run several statements as ONE message on the main connection, returning
|
|
861
|
+
* the last statement's rows. Each statement commits on its own and
|
|
862
|
+
* execution stops at the first failure, as when sent one by one (measured
|
|
863
|
+
* on DuckDB-WASM, /test/query-slicing §13); the error does not say which
|
|
864
|
+
* statement failed.
|
|
865
|
+
*
|
|
866
|
+
* For statements on tables the main connection owns (fjader's identity
|
|
867
|
+
* dims, present tables, views): one connection per table. Deliberately no
|
|
868
|
+
* class, source or routing options: it always runs on the main connection
|
|
869
|
+
* as interaction (so lanes hold their slices while it runs). What it saves
|
|
870
|
+
* is queue entries: a dependent chain sent one by one goes to the back of
|
|
871
|
+
* the queue at every step.
|
|
872
|
+
*
|
|
873
|
+
* Logged as one record, kind 'batch', with the joined text and `count`.
|
|
874
|
+
* Without `queryIPC` (mocks, custom connections), or with
|
|
875
|
+
* `QueryEngineOptions.unbatch`, the statements go one by one.
|
|
876
|
+
*/
|
|
877
|
+
async execBatch(statements) {
|
|
878
|
+
if (this.#destroyed)
|
|
879
|
+
throw new Error('QueryEngine has been destroyed');
|
|
880
|
+
if (statements.length === 0)
|
|
881
|
+
throw new Error('execBatch: no statements');
|
|
882
|
+
if (!this.#conn)
|
|
883
|
+
await this.#ensureConnection();
|
|
884
|
+
const place = { class: 'interaction' };
|
|
885
|
+
let result;
|
|
886
|
+
if (this.#unbatch || !this.#conn?.queryIPC) {
|
|
887
|
+
for (const stmt of statements.slice(0, -1))
|
|
888
|
+
await this.exec(stmt);
|
|
889
|
+
result = await this.#queryRaw(statements[statements.length - 1], place);
|
|
890
|
+
}
|
|
891
|
+
else {
|
|
892
|
+
const sql = statements.join(';\n');
|
|
893
|
+
result = await this.#send('batch', sql, place, {
|
|
894
|
+
run: async (conn) => tableFromIPC(await conn.queryIPC(sql)),
|
|
895
|
+
count: statements.length
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
for (const stmt of statements)
|
|
899
|
+
this.#noteDDL(stmt);
|
|
900
|
+
return result;
|
|
901
|
+
}
|
|
417
902
|
/**
|
|
418
903
|
* Execute DDL/DML statements (CREATE TABLE, DROP TABLE, INSERT, etc.).
|
|
419
904
|
* Accepts a single SQL string or an array of statements to execute
|
|
420
905
|
* sequentially.
|
|
421
906
|
*/
|
|
422
|
-
async exec(sql) {
|
|
907
|
+
async exec(sql, opts) {
|
|
423
908
|
if (this.#destroyed) {
|
|
424
909
|
throw new Error('QueryEngine has been destroyed');
|
|
425
910
|
}
|
|
426
|
-
|
|
427
|
-
|
|
911
|
+
const place = {
|
|
912
|
+
class: opts?.class ?? 'interaction',
|
|
913
|
+
connectionLocal: opts?.connectionLocal
|
|
914
|
+
};
|
|
915
|
+
const signal = opts?.signal;
|
|
428
916
|
const statements = Array.isArray(sql) ? sql : [sql];
|
|
429
|
-
|
|
917
|
+
// One statement at a time, each committing on its own.
|
|
430
918
|
for (const stmt of statements) {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
919
|
+
await this.#send('exec', stmt, place, {
|
|
920
|
+
run: async (conn) => {
|
|
921
|
+
if (conn.exec)
|
|
922
|
+
await conn.exec(stmt);
|
|
923
|
+
else
|
|
924
|
+
await conn.query(stmt);
|
|
925
|
+
},
|
|
926
|
+
runSliced: async (lane) => {
|
|
927
|
+
await lane.exec(stmt, signal);
|
|
928
|
+
},
|
|
929
|
+
signal
|
|
930
|
+
});
|
|
931
|
+
this.#noteDDL(stmt);
|
|
437
932
|
}
|
|
438
933
|
}
|
|
934
|
+
/**
|
|
935
|
+
* Learn what a source is from the statement that made it (SourceRouting's
|
|
936
|
+
* prior), at no cost: a table created under a plain name starts fast, a
|
|
937
|
+
* view unknown, a dropped name forgets. Covers everything created through
|
|
938
|
+
* the engine (loadParquet, Arrow inserts, fjader's own tables); a source
|
|
939
|
+
* made elsewhere falls back to the catalog (#classifySource).
|
|
940
|
+
*/
|
|
941
|
+
#noteDDL(sql) {
|
|
942
|
+
const ddl = sourceDDL(sql);
|
|
943
|
+
if (!ddl)
|
|
944
|
+
return;
|
|
945
|
+
if (ddl.kind === 'drop') {
|
|
946
|
+
this.#known.delete(ddl.name);
|
|
947
|
+
this.#localSources.delete(ddl.name);
|
|
948
|
+
this.#routing.forget(ddl.name);
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
this.#known.set(ddl.name, ddl.kind);
|
|
952
|
+
if (ddl.temp)
|
|
953
|
+
this.#localSources.add(ddl.name);
|
|
954
|
+
else
|
|
955
|
+
this.#localSources.delete(ddl.name);
|
|
956
|
+
// Replacing a name re-opens its record: a view over a table, a new table.
|
|
957
|
+
this.#routing.forget(ddl.name);
|
|
958
|
+
if (ddl.kind === 'table')
|
|
959
|
+
this.#routing.prior(ddl.name, 'fast');
|
|
960
|
+
}
|
|
961
|
+
/** Sources seen created through the engine, by name. */
|
|
962
|
+
#known = new Map();
|
|
963
|
+
/**
|
|
964
|
+
* Sources that exist only on the main connection: TEMP tables and views
|
|
965
|
+
* (they belong to the connection that made them), whether seen created
|
|
966
|
+
* through the engine or found missing on a lane. Never routed to a lane,
|
|
967
|
+
* whatever their cost.
|
|
968
|
+
*/
|
|
969
|
+
#localSources = new Set();
|
|
439
970
|
/**
|
|
440
971
|
* Get column info for a table via DESCRIBE.
|
|
441
972
|
* Results are cached — same table returns the same promise.
|
|
@@ -449,13 +980,20 @@ export class QueryEngine {
|
|
|
449
980
|
return cached;
|
|
450
981
|
const promise = this.#fetchDescribe(table);
|
|
451
982
|
this.#describeCache.set(table, promise);
|
|
983
|
+
// A failure is not remembered: a DESCRIBE sent before its table existed
|
|
984
|
+
// would otherwise fail every later one for good.
|
|
985
|
+
promise.catch(() => {
|
|
986
|
+
if (this.#describeCache.get(table) === promise)
|
|
987
|
+
this.#describeCache.delete(table);
|
|
988
|
+
});
|
|
452
989
|
return promise;
|
|
453
990
|
}
|
|
454
991
|
async #fetchDescribe(table) {
|
|
455
|
-
if (!this.#conn)
|
|
456
|
-
await this.#ensureConnection();
|
|
457
992
|
const escaped = table.replaceAll('"', '""');
|
|
458
|
-
|
|
993
|
+
// A source not seen created through the engine is looked up in the
|
|
994
|
+
// catalog, off the critical path: until the answer it reads sliced.
|
|
995
|
+
void this.#classifySource(table);
|
|
996
|
+
const result = await this.#queryRaw(`DESCRIBE "${escaped}"`, { class: 'interaction' }, 'describe');
|
|
459
997
|
const columns = [];
|
|
460
998
|
for (let i = 0; i < result.numRows; i++) {
|
|
461
999
|
const row = result.get(i);
|
|
@@ -467,6 +1005,67 @@ export class QueryEngine {
|
|
|
467
1005
|
}
|
|
468
1006
|
return columns;
|
|
469
1007
|
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Set a source's prior from what it is (SourceRouting): a base or
|
|
1010
|
+
* temporary table in an in-memory catalog starts fast; a view, or a
|
|
1011
|
+
* table in an attached database with a path (a file, a URL), stays
|
|
1012
|
+
* unknown and is read sliced until measured. Only where lanes exist: a
|
|
1013
|
+
* single connection has nothing to route.
|
|
1014
|
+
*/
|
|
1015
|
+
async #classifySource(table) {
|
|
1016
|
+
if (!this.#conn?.openSlicedLane)
|
|
1017
|
+
return;
|
|
1018
|
+
if (this.#known.has(table))
|
|
1019
|
+
return;
|
|
1020
|
+
let known = this.#catalog?.get(table);
|
|
1021
|
+
if (known === undefined)
|
|
1022
|
+
known = (await this.#refreshCatalog()).get(table);
|
|
1023
|
+
if (known === 'fast')
|
|
1024
|
+
this.#routing.prior(table, 'fast');
|
|
1025
|
+
}
|
|
1026
|
+
/**
|
|
1027
|
+
* Every user table, by name: 'fast' when every catalog holding that name
|
|
1028
|
+
* is in memory, 'unknown' otherwise. A name not in it (a view, a table
|
|
1029
|
+
* function) stays unknown, so the views need not be listed: duckdb_views()
|
|
1030
|
+
* enumerates DuckDB's internal views before any filter and was most of
|
|
1031
|
+
* the cost (information_schema per table: 30-38 ms at load on WASM;
|
|
1032
|
+
* tables and views together still 33-41 ms; tables and databases alone
|
|
1033
|
+
* about a quarter of that, natively). Refreshed when a name is missing;
|
|
1034
|
+
* concurrent misses (a dozen marks mounting) share the refresh in flight.
|
|
1035
|
+
*/
|
|
1036
|
+
#refreshCatalog() {
|
|
1037
|
+
this.#catalogRefresh ??= this.#queryRaw(`SELECT t.table_name AS name, d.path AS path FROM duckdb_tables() t ` +
|
|
1038
|
+
`JOIN duckdb_databases() d ON d.database_name = t.database_name WHERE NOT t.internal`, { class: 'interaction' })
|
|
1039
|
+
.then((rows) => {
|
|
1040
|
+
const catalog = new Map();
|
|
1041
|
+
for (let i = 0; i < rows.numRows; i++) {
|
|
1042
|
+
const row = rows.get(i);
|
|
1043
|
+
const inMemory = !row.path || row.path === ':memory:';
|
|
1044
|
+
// A name in two catalogs is fast only if every one is.
|
|
1045
|
+
if (catalog.get(row.name) !== 'unknown') {
|
|
1046
|
+
catalog.set(row.name, inMemory ? 'fast' : 'unknown');
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
this.#catalog = catalog;
|
|
1050
|
+
return catalog;
|
|
1051
|
+
})
|
|
1052
|
+
.catch(() => this.#catalog ?? new Map())
|
|
1053
|
+
.finally(() => {
|
|
1054
|
+
this.#catalogRefresh = null;
|
|
1055
|
+
});
|
|
1056
|
+
return this.#catalogRefresh;
|
|
1057
|
+
}
|
|
1058
|
+
#catalog = null;
|
|
1059
|
+
#catalogRefresh = null;
|
|
1060
|
+
/**
|
|
1061
|
+
* Tell the engine how a source reads, overriding its prior: `'slow'` for a
|
|
1062
|
+
* table that is slow to query (it reads sliced, so it never holds up
|
|
1063
|
+
* other charts), `'fast'` for a view that is cheap (it reads on the main
|
|
1064
|
+
* connection from its first query). A measured read still corrects it.
|
|
1065
|
+
*/
|
|
1066
|
+
sourceCost(source, cost) {
|
|
1067
|
+
this.#routing.hint(source, cost);
|
|
1068
|
+
}
|
|
470
1069
|
/** Clear cached DESCRIBE results. Pass a table name to clear one, or omit for all. */
|
|
471
1070
|
clearDescribeCache(table) {
|
|
472
1071
|
if (table) {
|
|
@@ -509,6 +1108,16 @@ export class QueryEngine {
|
|
|
509
1108
|
// against dropped state (see CategoricalIdentity's generation counter).
|
|
510
1109
|
const identityReset = this.#identity.reset().finally(() => this.#notifyIdentityReset());
|
|
511
1110
|
this.#preagg.reset();
|
|
1111
|
+
// New data: a source's cost is unknown again (a reload can be slower),
|
|
1112
|
+
// and the catalog is read again.
|
|
1113
|
+
this.#routing.reset();
|
|
1114
|
+
this.#catalog = null;
|
|
1115
|
+
// Tables learned from DDL are still tables: their prior comes back
|
|
1116
|
+
// (#classifySource skips names it already knows).
|
|
1117
|
+
for (const [name, kind] of this.#known) {
|
|
1118
|
+
if (kind === 'table')
|
|
1119
|
+
this.#routing.prior(name, 'fast');
|
|
1120
|
+
}
|
|
512
1121
|
this.#queryCache.clear();
|
|
513
1122
|
return identityReset;
|
|
514
1123
|
}
|
|
@@ -538,13 +1147,17 @@ export class QueryEngine {
|
|
|
538
1147
|
* Throws if the underlying connection doesn't support it (e.g. REST).
|
|
539
1148
|
*/
|
|
540
1149
|
async insertArrowFromIPCStream(data, options) {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
1150
|
+
await this.#send('insert', `INSERT ARROW INTO "${options.name}" (${data.byteLength} bytes)`, { class: 'interaction' }, {
|
|
1151
|
+
run: async (conn) => {
|
|
1152
|
+
if (!conn.insertArrowFromIPCStream) {
|
|
1153
|
+
throw new Error('Arrow IPC loading requires a DuckDB-WASM connection');
|
|
1154
|
+
}
|
|
1155
|
+
await conn.insertArrowFromIPCStream(data, options);
|
|
1156
|
+
}
|
|
1157
|
+
});
|
|
1158
|
+
// The notebook widget's load path: a table made from Arrow is a table.
|
|
1159
|
+
if (options.create)
|
|
1160
|
+
this.#noteDDL(`CREATE TABLE "${options.name.replaceAll('"', '""')}" (x)`);
|
|
548
1161
|
}
|
|
549
1162
|
// -----------------------------------------------------------------
|
|
550
1163
|
// v3: Mark registration + batch filter queries
|
|
@@ -571,15 +1184,29 @@ export class QueryEngine {
|
|
|
571
1184
|
}
|
|
572
1185
|
// Reuse existing handle to preserve arrowTable during re-query
|
|
573
1186
|
const handle = prev?.handle ?? new MarkHandle();
|
|
574
|
-
const reg = {
|
|
1187
|
+
const reg = {
|
|
1188
|
+
descriptor,
|
|
1189
|
+
handle,
|
|
1190
|
+
filterGroup,
|
|
1191
|
+
stale: false,
|
|
1192
|
+
needsFirst: !prev || prev.descriptor.parts !== descriptor.parts
|
|
1193
|
+
};
|
|
575
1194
|
this.#marks.set(markId, reg);
|
|
576
1195
|
this.#preagg.trackTable(descriptor.baseTable);
|
|
577
1196
|
handle._onParamsChange = () => this.#queryMark(markId, reg);
|
|
1197
|
+
handle._onActivate = () => {
|
|
1198
|
+
if (!reg.stale)
|
|
1199
|
+
return false;
|
|
1200
|
+
reg.stale = false;
|
|
1201
|
+
this.#queryMark(markId, reg, 'catchUp');
|
|
1202
|
+
return true;
|
|
1203
|
+
};
|
|
578
1204
|
handle.destroy = () => {
|
|
579
1205
|
this.cancel(markId);
|
|
580
1206
|
this.#preagg.dropMark(markId);
|
|
581
1207
|
this.#marks.delete(markId);
|
|
582
1208
|
handle._onParamsChange = null;
|
|
1209
|
+
handle._onActivate = null;
|
|
583
1210
|
};
|
|
584
1211
|
// Re-registration: trigger re-query immediately with updated descriptor.
|
|
585
1212
|
// The params effect won't fire since the handle reference didn't change.
|
|
@@ -644,16 +1271,37 @@ export class QueryEngine {
|
|
|
644
1271
|
* Query a single mark: on a filter change or a params
|
|
645
1272
|
* change (via _onParamsChange). Reads a preagg table when one answers; if
|
|
646
1273
|
* that read fails, the table is set aside and the base table is queried once.
|
|
1274
|
+
*
|
|
1275
|
+
* `cause` travels with this query's own promise chain to `_setResult`, not
|
|
1276
|
+
* on the handle: a catch-up in flight and an update waiting behind it are
|
|
1277
|
+
* two chains, so each result lands with its own cause. A filter change
|
|
1278
|
+
* during a catch-up lets the catch-up land (without animating) and the
|
|
1279
|
+
* update then animates from there; a superseded pending query rejects, and
|
|
1280
|
+
* its chain sets nothing.
|
|
647
1281
|
*/
|
|
648
|
-
#queryMark(markId, reg) {
|
|
1282
|
+
#queryMark(markId, reg, cause = 'update') {
|
|
649
1283
|
const { handle } = reg;
|
|
1284
|
+
// Off screen and already holding data for this registration: nothing to
|
|
1285
|
+
// show for it now. A registration's first query still runs (it can feed
|
|
1286
|
+
// a domain another chart shares), including after a re-registration
|
|
1287
|
+
// with new parts, where the handle still holds the old data.
|
|
1288
|
+
if (!handle.active && handle.dataVersion > 0 && !reg.needsFirst) {
|
|
1289
|
+
reg.stale = true;
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
reg.stale = false;
|
|
650
1293
|
const preagg = this.#preagg.sqlFor(markId, reg);
|
|
651
1294
|
const sqlStr = preagg?.sql || this.#baseSQL(markId, reg);
|
|
652
1295
|
if (!sqlStr)
|
|
653
1296
|
return;
|
|
1297
|
+
// Only once a first query actually goes out.
|
|
1298
|
+
reg.needsFirst = false;
|
|
654
1299
|
// Use the public query() path — its execute-latest logic ensures
|
|
655
1300
|
// at most 1 inflight + 1 pending per callerId during rapid brushing.
|
|
656
|
-
|
|
1301
|
+
// A preagg table is built in memory by fjader: fast until measured.
|
|
1302
|
+
if (preagg)
|
|
1303
|
+
this.#routing.prior(preagg.table, 'fast');
|
|
1304
|
+
this.query(sqlStr, markReadOptions(markId, reg.descriptor, preagg?.table ?? null))
|
|
657
1305
|
.catch((e) => {
|
|
658
1306
|
if (!preagg || e?.name === 'AbortError')
|
|
659
1307
|
throw e;
|
|
@@ -661,9 +1309,9 @@ export class QueryEngine {
|
|
|
661
1309
|
const baseSQL = this.#baseSQL(markId, reg);
|
|
662
1310
|
if (!baseSQL)
|
|
663
1311
|
throw e;
|
|
664
|
-
return this.query(baseSQL,
|
|
1312
|
+
return this.query(baseSQL, markReadOptions(markId, reg.descriptor, null));
|
|
665
1313
|
})
|
|
666
|
-
.then((table) => handle._setResult(table))
|
|
1314
|
+
.then((table) => handle._setResult(table, cause))
|
|
667
1315
|
.catch((e) => {
|
|
668
1316
|
if (e?.name === 'AbortError')
|
|
669
1317
|
return;
|
|
@@ -756,6 +1404,8 @@ export class QueryEngine {
|
|
|
756
1404
|
pending.reject(new DOMException('Engine destroyed', 'AbortError'));
|
|
757
1405
|
}
|
|
758
1406
|
this.#pending.clear();
|
|
1407
|
+
// The preagg drops above ran on the lane; close it, then the connection.
|
|
1408
|
+
await Promise.all([this.#backgroundLane, ...this.#sourceLanes].map((slot) => slot.close()));
|
|
759
1409
|
const conn = this.#conn ?? (this.#connPromise ? await this.#connPromise : null);
|
|
760
1410
|
if (conn)
|
|
761
1411
|
await conn.close();
|