@fjader/svelte 0.2.4 → 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 +1 -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 +4 -2
- package/dist/components/axes/AxisCenter.svelte +4 -2
- package/dist/components/axes/AxisLeft.svelte +4 -2
- package/dist/components/axes/BandAxisTicks.svelte +3 -2
- package/dist/components/charts/ChartCell.svelte +32 -5
- package/dist/components/charts/ChartCell.svelte.d.ts +9 -0
- package/dist/components/charts/ChartContainer.svelte +45 -3
- package/dist/components/charts/ChartContainer.svelte.d.ts +7 -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/marks/AreaMarkX.svelte +16 -4
- package/dist/components/marks/AreaMarkY.svelte +16 -4
- package/dist/components/marks/BarMarkX.svelte +7 -3
- package/dist/components/marks/BarMarkY.svelte +7 -3
- package/dist/components/marks/DotsMark.svelte +6 -3
- package/dist/components/marks/LineMark.svelte +11 -8
- package/dist/components/marks/ParallelCoordinatesMark.svelte +7 -2
- package/dist/components/marks/ParallelCoordinatesMark.svelte.d.ts +2 -0
- package/dist/components/marks/RectMark.svelte +6 -3
- package/dist/components/marks/RectXMark.svelte +7 -3
- package/dist/components/marks/RectYMark.svelte +7 -3
- package/dist/components/marks/RuleMarkX.svelte +30 -26
- package/dist/components/marks/RuleMarkY.svelte +30 -26
- package/dist/components/marks/TextMark.svelte +44 -5
- package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.d.ts +16 -0
- package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.js +398 -54
- package/dist/components/marks/lifecycle/domain-queries.d.ts +11 -2
- package/dist/components/marks/lifecycle/domain-queries.js +33 -6
- package/dist/components/marks/lifecycle/domain-registration.d.ts +6 -1
- package/dist/components/marks/lifecycle/domain-registration.js +15 -14
- package/dist/components/marks/lifecycle/other-rows.svelte.d.ts +9 -1
- package/dist/components/marks/lifecycle/other-rows.svelte.js +2 -2
- package/dist/components/marks/lifecycle/query-builder.d.ts +27 -2
- package/dist/components/marks/lifecycle/query-builder.js +23 -20
- 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 +3 -0
- 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 +1 -1
- 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 +51 -11
- package/dist/components/rendering/Rects.svelte.d.ts +16 -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 +41 -24
- package/dist/components/rendering/activity-reads.js +54 -31
- 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 +7 -0
- 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 -2
- package/dist/gpu/MorphedChannels.js +33 -11
- package/dist/gpu/ScaleTemplateSystem.d.ts +20 -2
- package/dist/gpu/ScaleTemplateSystem.js +58 -2
- package/dist/index.d.ts +17 -4
- package/dist/index.js +19 -4
- package/dist/interactors/TooltipManager.svelte.js +14 -5
- 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 +2 -3
- package/dist/query-engine/FieldActivity.svelte.js +1 -2
- 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 +671 -41
- package/dist/query-engine/categorical-identity.d.ts +34 -8
- package/dist/query-engine/categorical-identity.js +138 -73
- 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 +28 -1
- package/dist/query-engine/preagg/plan.js +128 -8
- 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/scales/BandScaleState.svelte.d.ts +9 -1
- package/dist/scales/BandScaleState.svelte.js +16 -5
- 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 +41 -13
- package/dist/sql/bin.d.ts +25 -0
- package/dist/sql/bin.js +108 -5
- package/dist/transforms/TransformCompiler.d.ts +10 -0
- package/dist/transforms/TransformCompiler.js +26 -0
- package/dist/transforms/index.js +144 -24
- package/dist/types.d.ts +136 -2
- package/package.json +2 -2
|
@@ -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;
|
|
@@ -126,19 +185,44 @@ export class QueryEngine {
|
|
|
126
185
|
rows.push(table.get(i));
|
|
127
186
|
}
|
|
128
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;
|
|
129
206
|
}
|
|
130
207
|
}, this.#session);
|
|
131
208
|
/** Notified after clearQueryCache() drops the identity dims and views. */
|
|
132
209
|
#identityResetListeners = new Set();
|
|
133
210
|
constructor(connection, options = {}) {
|
|
134
211
|
this.#preagg = new Preagg({
|
|
135
|
-
exec: (sql) => this.exec(sql),
|
|
136
|
-
query: (sql, opts) => this.query(sql, opts),
|
|
137
|
-
|
|
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(),
|
|
138
218
|
identity: this.#identity,
|
|
139
219
|
mark: (markId) => this.#marks.get(markId),
|
|
140
|
-
marks: () => this.#marks
|
|
220
|
+
marks: () => this.#marks,
|
|
221
|
+
observingBuilds: () => this.#buildObservers.size > 0,
|
|
222
|
+
onBuild: (event) => this.#notifyBuild(event)
|
|
141
223
|
}, options.buildMaxWaitMs ?? BUILD_MAX_WAIT_MS);
|
|
224
|
+
this.#sliceShareMs = options.sliceShareMs ?? SLICE_SHARE_MS;
|
|
225
|
+
this.#unbatch = options.unbatch ?? false;
|
|
142
226
|
if (connection instanceof Promise) {
|
|
143
227
|
this.#connPromise = connection;
|
|
144
228
|
connection.then((c) => {
|
|
@@ -293,18 +377,316 @@ export class QueryEngine {
|
|
|
293
377
|
}
|
|
294
378
|
}
|
|
295
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
|
+
}
|
|
296
451
|
/**
|
|
297
452
|
* Execute a SQL query and return a flechette Table.
|
|
298
453
|
* Uses the fast IPC path (queryIPC → tableFromIPC) when available,
|
|
299
454
|
* falling back to the standard conn.query() for mocks/testing.
|
|
300
455
|
*/
|
|
301
|
-
|
|
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 = () => {
|
|
302
532
|
const conn = this.#conn;
|
|
303
|
-
if (conn
|
|
304
|
-
|
|
305
|
-
|
|
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;
|
|
564
|
+
}
|
|
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
|
+
}
|
|
306
680
|
}
|
|
307
|
-
|
|
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);
|
|
308
690
|
}
|
|
309
691
|
/** Resolve #conn from #connPromise if needed. No-op when already resolved. */
|
|
310
692
|
#ensureConnection() {
|
|
@@ -332,6 +714,15 @@ export class QueryEngine {
|
|
|
332
714
|
if (!this.#conn)
|
|
333
715
|
await this.#ensureConnection();
|
|
334
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
|
+
};
|
|
335
726
|
// Cache check: return cached result immediately, bypassing
|
|
336
727
|
// inflight/pending bookkeeping entirely.
|
|
337
728
|
// Opt out with cache: false for queries against volatile temp tables.
|
|
@@ -356,23 +747,54 @@ export class QueryEngine {
|
|
|
356
747
|
}
|
|
357
748
|
// Store as pending — will be executed when inflight completes
|
|
358
749
|
return new Promise((resolve, reject) => {
|
|
359
|
-
this.#pending.set(callerId, {
|
|
750
|
+
this.#pending.set(callerId, {
|
|
751
|
+
sql: sqlStr,
|
|
752
|
+
resolve,
|
|
753
|
+
reject,
|
|
754
|
+
cache: useCache,
|
|
755
|
+
place
|
|
756
|
+
});
|
|
360
757
|
});
|
|
361
758
|
}
|
|
362
|
-
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;
|
|
363
785
|
}
|
|
364
786
|
// No callerId: direct execution, still respect external signal
|
|
365
787
|
if (opts?.signal) {
|
|
366
788
|
if (opts.signal.aborted) {
|
|
367
789
|
throw new DOMException('Query aborted', 'AbortError');
|
|
368
790
|
}
|
|
369
|
-
const result = await this.#queryRaw(sqlStr);
|
|
791
|
+
const result = await this.#queryRaw(sqlStr, place, 'query', opts.signal);
|
|
370
792
|
if (opts.signal.aborted) {
|
|
371
793
|
throw new DOMException('Query aborted', 'AbortError');
|
|
372
794
|
}
|
|
373
795
|
return result;
|
|
374
796
|
}
|
|
375
|
-
return this.#queryRaw(sqlStr);
|
|
797
|
+
return this.#queryRaw(sqlStr, place);
|
|
376
798
|
}
|
|
377
799
|
/**
|
|
378
800
|
* The cache key for a query: its text and the dims' version, so a result read
|
|
@@ -381,7 +803,7 @@ export class QueryEngine {
|
|
|
381
803
|
#cacheKey(sqlStr) {
|
|
382
804
|
return `${this.#identity.version}\u0000${sqlStr}`;
|
|
383
805
|
}
|
|
384
|
-
async #executeForCaller(callerId, sqlStr, signal, cache = true) {
|
|
806
|
+
async #executeForCaller(callerId, sqlStr, signal, cache = true, place = { class: 'interaction' }) {
|
|
385
807
|
const controller = new AbortController();
|
|
386
808
|
this.#inflight.set(callerId, controller);
|
|
387
809
|
let key = null;
|
|
@@ -396,7 +818,7 @@ export class QueryEngine {
|
|
|
396
818
|
// later, against whatever the dims hold by then.
|
|
397
819
|
key = this.#cacheKey(sqlStr);
|
|
398
820
|
// Cache the Promise so concurrent identical queries share one inflight
|
|
399
|
-
const queryPromise = this.#queryRaw(sqlStr);
|
|
821
|
+
const queryPromise = this.#queryRaw(sqlStr, place, 'query', controller.signal);
|
|
400
822
|
if (cache)
|
|
401
823
|
this.#queryCache.set(key, queryPromise);
|
|
402
824
|
const result = await queryPromise;
|
|
@@ -426,7 +848,7 @@ export class QueryEngine {
|
|
|
426
848
|
const pending = this.#pending.get(callerId);
|
|
427
849
|
if (pending) {
|
|
428
850
|
this.#pending.delete(callerId);
|
|
429
|
-
this.#executeForCaller(callerId, pending.sql, undefined, pending.cache)
|
|
851
|
+
this.#executeForCaller(callerId, pending.sql, undefined, pending.cache, pending.place)
|
|
430
852
|
.then(pending.resolve)
|
|
431
853
|
.catch(pending.reject);
|
|
432
854
|
}
|
|
@@ -434,28 +856,117 @@ export class QueryEngine {
|
|
|
434
856
|
this.#preagg.pump();
|
|
435
857
|
}
|
|
436
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
|
+
}
|
|
437
902
|
/**
|
|
438
903
|
* Execute DDL/DML statements (CREATE TABLE, DROP TABLE, INSERT, etc.).
|
|
439
904
|
* Accepts a single SQL string or an array of statements to execute
|
|
440
905
|
* sequentially.
|
|
441
906
|
*/
|
|
442
|
-
async exec(sql) {
|
|
907
|
+
async exec(sql, opts) {
|
|
443
908
|
if (this.#destroyed) {
|
|
444
909
|
throw new Error('QueryEngine has been destroyed');
|
|
445
910
|
}
|
|
446
|
-
|
|
447
|
-
|
|
911
|
+
const place = {
|
|
912
|
+
class: opts?.class ?? 'interaction',
|
|
913
|
+
connectionLocal: opts?.connectionLocal
|
|
914
|
+
};
|
|
915
|
+
const signal = opts?.signal;
|
|
448
916
|
const statements = Array.isArray(sql) ? sql : [sql];
|
|
449
|
-
|
|
917
|
+
// One statement at a time, each committing on its own.
|
|
450
918
|
for (const stmt of statements) {
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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);
|
|
457
932
|
}
|
|
458
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();
|
|
459
970
|
/**
|
|
460
971
|
* Get column info for a table via DESCRIBE.
|
|
461
972
|
* Results are cached — same table returns the same promise.
|
|
@@ -469,13 +980,20 @@ export class QueryEngine {
|
|
|
469
980
|
return cached;
|
|
470
981
|
const promise = this.#fetchDescribe(table);
|
|
471
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
|
+
});
|
|
472
989
|
return promise;
|
|
473
990
|
}
|
|
474
991
|
async #fetchDescribe(table) {
|
|
475
|
-
if (!this.#conn)
|
|
476
|
-
await this.#ensureConnection();
|
|
477
992
|
const escaped = table.replaceAll('"', '""');
|
|
478
|
-
|
|
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');
|
|
479
997
|
const columns = [];
|
|
480
998
|
for (let i = 0; i < result.numRows; i++) {
|
|
481
999
|
const row = result.get(i);
|
|
@@ -487,6 +1005,67 @@ export class QueryEngine {
|
|
|
487
1005
|
}
|
|
488
1006
|
return columns;
|
|
489
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
|
+
}
|
|
490
1069
|
/** Clear cached DESCRIBE results. Pass a table name to clear one, or omit for all. */
|
|
491
1070
|
clearDescribeCache(table) {
|
|
492
1071
|
if (table) {
|
|
@@ -529,6 +1108,16 @@ export class QueryEngine {
|
|
|
529
1108
|
// against dropped state (see CategoricalIdentity's generation counter).
|
|
530
1109
|
const identityReset = this.#identity.reset().finally(() => this.#notifyIdentityReset());
|
|
531
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
|
+
}
|
|
532
1121
|
this.#queryCache.clear();
|
|
533
1122
|
return identityReset;
|
|
534
1123
|
}
|
|
@@ -558,13 +1147,17 @@ export class QueryEngine {
|
|
|
558
1147
|
* Throws if the underlying connection doesn't support it (e.g. REST).
|
|
559
1148
|
*/
|
|
560
1149
|
async insertArrowFromIPCStream(data, options) {
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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)`);
|
|
568
1161
|
}
|
|
569
1162
|
// -----------------------------------------------------------------
|
|
570
1163
|
// v3: Mark registration + batch filter queries
|
|
@@ -591,15 +1184,29 @@ export class QueryEngine {
|
|
|
591
1184
|
}
|
|
592
1185
|
// Reuse existing handle to preserve arrowTable during re-query
|
|
593
1186
|
const handle = prev?.handle ?? new MarkHandle();
|
|
594
|
-
const reg = {
|
|
1187
|
+
const reg = {
|
|
1188
|
+
descriptor,
|
|
1189
|
+
handle,
|
|
1190
|
+
filterGroup,
|
|
1191
|
+
stale: false,
|
|
1192
|
+
needsFirst: !prev || prev.descriptor.parts !== descriptor.parts
|
|
1193
|
+
};
|
|
595
1194
|
this.#marks.set(markId, reg);
|
|
596
1195
|
this.#preagg.trackTable(descriptor.baseTable);
|
|
597
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
|
+
};
|
|
598
1204
|
handle.destroy = () => {
|
|
599
1205
|
this.cancel(markId);
|
|
600
1206
|
this.#preagg.dropMark(markId);
|
|
601
1207
|
this.#marks.delete(markId);
|
|
602
1208
|
handle._onParamsChange = null;
|
|
1209
|
+
handle._onActivate = null;
|
|
603
1210
|
};
|
|
604
1211
|
// Re-registration: trigger re-query immediately with updated descriptor.
|
|
605
1212
|
// The params effect won't fire since the handle reference didn't change.
|
|
@@ -664,16 +1271,37 @@ export class QueryEngine {
|
|
|
664
1271
|
* Query a single mark: on a filter change or a params
|
|
665
1272
|
* change (via _onParamsChange). Reads a preagg table when one answers; if
|
|
666
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.
|
|
667
1281
|
*/
|
|
668
|
-
#queryMark(markId, reg) {
|
|
1282
|
+
#queryMark(markId, reg, cause = 'update') {
|
|
669
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;
|
|
670
1293
|
const preagg = this.#preagg.sqlFor(markId, reg);
|
|
671
1294
|
const sqlStr = preagg?.sql || this.#baseSQL(markId, reg);
|
|
672
1295
|
if (!sqlStr)
|
|
673
1296
|
return;
|
|
1297
|
+
// Only once a first query actually goes out.
|
|
1298
|
+
reg.needsFirst = false;
|
|
674
1299
|
// Use the public query() path — its execute-latest logic ensures
|
|
675
1300
|
// at most 1 inflight + 1 pending per callerId during rapid brushing.
|
|
676
|
-
|
|
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))
|
|
677
1305
|
.catch((e) => {
|
|
678
1306
|
if (!preagg || e?.name === 'AbortError')
|
|
679
1307
|
throw e;
|
|
@@ -681,9 +1309,9 @@ export class QueryEngine {
|
|
|
681
1309
|
const baseSQL = this.#baseSQL(markId, reg);
|
|
682
1310
|
if (!baseSQL)
|
|
683
1311
|
throw e;
|
|
684
|
-
return this.query(baseSQL,
|
|
1312
|
+
return this.query(baseSQL, markReadOptions(markId, reg.descriptor, null));
|
|
685
1313
|
})
|
|
686
|
-
.then((table) => handle._setResult(table))
|
|
1314
|
+
.then((table) => handle._setResult(table, cause))
|
|
687
1315
|
.catch((e) => {
|
|
688
1316
|
if (e?.name === 'AbortError')
|
|
689
1317
|
return;
|
|
@@ -776,6 +1404,8 @@ export class QueryEngine {
|
|
|
776
1404
|
pending.reject(new DOMException('Engine destroyed', 'AbortError'));
|
|
777
1405
|
}
|
|
778
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()));
|
|
779
1409
|
const conn = this.#conn ?? (this.#connPromise ? await this.#connPromise : null);
|
|
780
1410
|
if (conn)
|
|
781
1411
|
await conn.close();
|