@query-farm/vgi 0.31.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/catalog/item-encoding.d.ts +17 -0
- package/dist/catalog/read-only.d.ts +16 -0
- package/dist/filter-pushdown/evaluate.d.ts +9 -0
- package/dist/filter-pushdown/history.d.ts +66 -0
- package/dist/filter-pushdown/index.d.ts +1 -0
- package/dist/functions/storage-cf-do.d.ts +2 -2
- package/dist/functions/storage.d.ts +39 -3
- package/dist/functions/table-in-out.d.ts +13 -8
- package/dist/functions/types.d.ts +14 -1
- package/dist/http/fetch.d.ts +1 -1
- package/dist/index.core.d.ts +1 -1
- package/dist/index.js +1325 -988
- package/dist/index.js.map +20 -16
- package/dist/protocol/carried-init-request.d.ts +28 -0
- package/dist/protocol/dispatch.d.ts +0 -7
- package/dist/protocol/handlers/function.d.ts +0 -1
- package/dist/protocol/handlers/shared.d.ts +0 -7
- package/dist/protocol/state-serializer.d.ts +29 -2
- package/dist/serve-entry.js +393 -190
- package/dist/serve-entry.js.map +16 -12
- package/dist/util/zstd-sync.d.ts +7 -0
- package/dist/worker-cf-entry.js +5045 -4712
- package/dist/worker-cf-entry.js.map +25 -21
- package/package.json +1 -1
- package/src/catalog/item-encoding.ts +59 -0
- package/src/catalog/read-only.ts +87 -19
- package/src/filter-pushdown/evaluate.ts +55 -8
- package/src/filter-pushdown/history.ts +239 -0
- package/src/filter-pushdown/index.ts +8 -0
- package/src/functions/storage-cf-do.ts +7 -12
- package/src/functions/storage.ts +54 -11
- package/src/functions/table-buffering.ts +0 -1
- package/src/functions/table-in-out.ts +99 -60
- package/src/functions/table.ts +41 -38
- package/src/functions/types.ts +14 -1
- package/src/http/fetch.ts +2 -17
- package/src/index.core.ts +1 -0
- package/src/protocol/carried-init-request.ts +139 -0
- package/src/protocol/dispatch.ts +0 -8
- package/src/protocol/handlers/catalog/admin.ts +5 -2
- package/src/protocol/handlers/function.ts +18 -15
- package/src/protocol/handlers/shared.ts +0 -22
- package/src/protocol/state-serializer.ts +109 -37
- package/src/util/zstd-sync.ts +50 -0
package/src/functions/storage.ts
CHANGED
|
@@ -84,9 +84,22 @@ function hexEncode(buf: Uint8Array): string {
|
|
|
84
84
|
// FunctionStorage interface
|
|
85
85
|
// ============================================================================
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Which slot of an execution's worker state a row occupies: a table-in-out
|
|
89
|
+
* substream's client-minted `substream_id` (raw bytes), or -- only for a client
|
|
90
|
+
* that sent none -- a numeric worker id, the process id (an int64 key). See
|
|
91
|
+
* {@link BoundStorage.put}.
|
|
92
|
+
*/
|
|
93
|
+
export type WorkerStateKey = number | Uint8Array;
|
|
94
|
+
|
|
87
95
|
export interface FunctionStorage {
|
|
88
|
-
|
|
96
|
+
/** Upsert `state` into the execution's worker-state slot `worker`. */
|
|
97
|
+
workerPut(executionId: Uint8Array, worker: WorkerStateKey, state: Uint8Array): Promise<void>;
|
|
98
|
+
/** Drain every worker-state slot of the execution, whatever its key. */
|
|
89
99
|
workerCollect(executionId: Uint8Array): Promise<Uint8Array[]>;
|
|
100
|
+
/** Read every worker-state slot without draining. A slot keyed by a
|
|
101
|
+
* numeric worker id reports that id; one keyed by bytes (a substream id)
|
|
102
|
+
* has none and reports 0. */
|
|
90
103
|
workerScan(executionId: Uint8Array): Promise<Array<[number, Uint8Array]>>;
|
|
91
104
|
queuePush(executionId: Uint8Array, items: Uint8Array[]): Promise<number>;
|
|
92
105
|
queuePop(executionId: Uint8Array): Promise<Uint8Array | null>;
|
|
@@ -148,8 +161,9 @@ function getDefaultDbPath(): string {
|
|
|
148
161
|
}
|
|
149
162
|
|
|
150
163
|
// Worker state rides the unified function_state table under a reserved
|
|
151
|
-
// namespace, keyed by the
|
|
152
|
-
//
|
|
164
|
+
// namespace, keyed by the substream id (its raw bytes) or, for a client that
|
|
165
|
+
// sent none, the worker/process id (8-byte big-endian) — exactly the mapping
|
|
166
|
+
// the Cloudflare DO client uses, so both backends share one schema.
|
|
153
167
|
const NS_WORKER = new TextEncoder().encode("worker");
|
|
154
168
|
|
|
155
169
|
/** Encode an int64 worker id as an 8-byte big-endian state key. */
|
|
@@ -159,6 +173,12 @@ function int64Key(v: number): Uint8Array {
|
|
|
159
173
|
return b;
|
|
160
174
|
}
|
|
161
175
|
|
|
176
|
+
/** The state key for a worker-state slot: a substream id as-is, a numeric
|
|
177
|
+
* worker id as an int64. */
|
|
178
|
+
export function workerStateKey(worker: WorkerStateKey): Uint8Array {
|
|
179
|
+
return typeof worker === "number" ? int64Key(worker) : worker;
|
|
180
|
+
}
|
|
181
|
+
|
|
162
182
|
function int64FromKey(b: Uint8Array): number {
|
|
163
183
|
if (b.length !== 8) return 0;
|
|
164
184
|
return Number(new DataView(b.buffer, b.byteOffset, 8).getBigInt64(0, false));
|
|
@@ -300,18 +320,19 @@ export class FunctionStorageSqlite implements FunctionStorage {
|
|
|
300
320
|
return total;
|
|
301
321
|
}
|
|
302
322
|
|
|
303
|
-
// Worker state rides function_state under ns=worker, keyed by
|
|
323
|
+
// Worker state rides function_state under ns=worker, keyed by substream id
|
|
324
|
+
// or worker id.
|
|
304
325
|
|
|
305
326
|
async workerPut(
|
|
306
327
|
executionId: Uint8Array,
|
|
307
|
-
|
|
328
|
+
worker: WorkerStateKey,
|
|
308
329
|
state: Uint8Array
|
|
309
330
|
): Promise<void> {
|
|
310
331
|
this._db
|
|
311
332
|
.prepare(
|
|
312
333
|
"INSERT OR REPLACE INTO function_state (scope_id, ns, key, value) VALUES (?, ?, ?, ?)"
|
|
313
334
|
)
|
|
314
|
-
.run(bufferArg(executionId), bufferArg(NS_WORKER), bufferArg(
|
|
335
|
+
.run(bufferArg(executionId), bufferArg(NS_WORKER), bufferArg(workerStateKey(worker)), bufferArg(state));
|
|
315
336
|
}
|
|
316
337
|
|
|
317
338
|
async workerCollect(executionId: Uint8Array): Promise<Uint8Array[]> {
|
|
@@ -381,14 +402,36 @@ export class FunctionStorageSqlite implements FunctionStorage {
|
|
|
381
402
|
export class BoundStorage {
|
|
382
403
|
private _base: FunctionStorage;
|
|
383
404
|
private _executionId: Uint8Array;
|
|
384
|
-
|
|
385
|
-
|
|
405
|
+
private _substreamId: Uint8Array | null;
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* @param substreamId The table-in-out substream this storage serves -- the
|
|
409
|
+
* client-minted `InitRequest.substream_id`, identical across the
|
|
410
|
+
* substream's init, every tick and its finalize -- or `null` when the
|
|
411
|
+
* client sent none. Keys {@link put}.
|
|
412
|
+
*/
|
|
413
|
+
constructor(storage: FunctionStorage, executionId: Uint8Array, substreamId: Uint8Array | null = null) {
|
|
386
414
|
this._base = storage;
|
|
387
415
|
this._executionId = executionId;
|
|
388
|
-
|
|
389
|
-
|
|
416
|
+
this._substreamId = substreamId;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Upsert this substream's state into the execution's worker state, which
|
|
421
|
+
* {@link collect} drains at finalize.
|
|
422
|
+
*
|
|
423
|
+
* One row per **substream**, keyed by its `substream_id`. Storage is
|
|
424
|
+
* already scoped to the execution, and every connection of a fanned-out
|
|
425
|
+
* scan shares that execution -- so the key has to tell those connections
|
|
426
|
+
* apart. The process id only does when each connection is its own process;
|
|
427
|
+
* under the launcher, TCP or HTTP one process serves many of them, and a
|
|
428
|
+
* per-process key let them overwrite each other's state so finish()
|
|
429
|
+
* undercounted. The pid remains only for a client that sends no
|
|
430
|
+
* `substream_id`, which it keys as before. Mirrors vgi-python's
|
|
431
|
+
* `TableInOutFunction.process` (072e543).
|
|
432
|
+
*/
|
|
390
433
|
put(state: Uint8Array): Promise<void> {
|
|
391
|
-
return this._base.workerPut(this._executionId, process.pid, state);
|
|
434
|
+
return this._base.workerPut(this._executionId, this._substreamId ?? process.pid, state);
|
|
392
435
|
}
|
|
393
436
|
|
|
394
437
|
collect(): Promise<Uint8Array[]> {
|
|
@@ -347,7 +347,6 @@ export function defineTableBufferingFunction<
|
|
|
347
347
|
createStreamHandlers(
|
|
348
348
|
request: InitRequest,
|
|
349
349
|
response: GlobalInitResponse,
|
|
350
|
-
_accumulatedState?: any,
|
|
351
350
|
): StreamHandlers {
|
|
352
351
|
const args = extractArgs(request.bind_call);
|
|
353
352
|
const settings = batchToScalarDict(request.bind_call.settings);
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Two-phase: INPUT phase receives and transforms batches,
|
|
4
4
|
// FINALIZE phase emits final results.
|
|
5
5
|
|
|
6
|
-
import { type VgiSchema, schema, type VgiField, type VgiDataType, type VgiBatch, nullType, withBatchMetadata, serializeBatch, deserializeBatch } from "../arrow/index.js";
|
|
6
|
+
import { type VgiSchema, schema, type VgiField, type VgiDataType, type VgiBatch, nullType, isNull, withBatchMetadata, serializeBatch, deserializeBatch } from "../arrow/index.js";
|
|
7
7
|
import type { OutputCollector } from "@query-farm/vgi-rpc";
|
|
8
8
|
import { DEFAULT_MAX_WORKERS, TableInOutPhase } from "../types.js";
|
|
9
9
|
import type {
|
|
@@ -33,6 +33,13 @@ import {
|
|
|
33
33
|
FilteringOutputCollector,
|
|
34
34
|
type PushdownFilters,
|
|
35
35
|
} from "../filter-pushdown/index.js";
|
|
36
|
+
import {
|
|
37
|
+
decodeDynamicFilterMetadata,
|
|
38
|
+
EMPTY_FILTER_HISTORY,
|
|
39
|
+
type FilterDeltaHistory,
|
|
40
|
+
recordFilterDelta,
|
|
41
|
+
replayFilterHistory,
|
|
42
|
+
} from "../filter-pushdown/history.js";
|
|
36
43
|
import { FunctionStability } from "../types.js";
|
|
37
44
|
import { BoundStorage, storage as defaultStorage, FrameworkNS } from "./storage.js";
|
|
38
45
|
import { serializeUserState, deserializeUserState } from "../protocol/state-serializer.js";
|
|
@@ -70,12 +77,14 @@ export interface TableInOutProcessParams<TArgs = Record<string, any>> {
|
|
|
70
77
|
storage: BoundStorage;
|
|
71
78
|
/**
|
|
72
79
|
* Stable client-minted id for this streaming table-in-out substream.
|
|
73
|
-
* Present (identical across init / every process() / finalize)
|
|
74
|
-
* client
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
80
|
+
* Present (identical across init / every process() / finalize) whenever the
|
|
81
|
+
* client sent one -- the DuckDB extension does for every substream. The
|
|
82
|
+
* framework keys the accumulated state it auto-persists by it
|
|
83
|
+
* ({@link BoundStorage.put}), so substreams of one execution that one
|
|
84
|
+
* process serves keep separate states and finalize() receives each; use it
|
|
85
|
+
* likewise for any per-substream state you keep in shared storage yourself.
|
|
86
|
+
* `null`/`undefined` for a client that sent none. Mirrors vgi-python's
|
|
87
|
+
* `ProcessParams.substream_id`.
|
|
79
88
|
*/
|
|
80
89
|
substreamId?: Uint8Array | null;
|
|
81
90
|
/**
|
|
@@ -298,7 +307,7 @@ export function defineTableInOutFunction<
|
|
|
298
307
|
createStreamHandlers(
|
|
299
308
|
request: InitRequest,
|
|
300
309
|
response: GlobalInitResponse,
|
|
301
|
-
|
|
310
|
+
filterHistory?: FilterDeltaHistory | null,
|
|
302
311
|
): StreamHandlers {
|
|
303
312
|
const args = extractArgs(request.bind_call);
|
|
304
313
|
const settings = batchToScalarDict(request.bind_call.settings);
|
|
@@ -315,20 +324,31 @@ export function defineTableInOutFunction<
|
|
|
315
324
|
// Deserialize pushdown filters. Pass a join-keys column lookup so that
|
|
316
325
|
// filters DuckDB promoted to join_keys (IN/OR lists, etc.) are
|
|
317
326
|
// materialized as InFilters rather than silently dropped.
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
327
|
+
// An HTTP continuation replays the dynamic-filter deltas its cursor
|
|
328
|
+
// carries, so the filters are the ones the previous exchange left.
|
|
329
|
+
const carriedFilterHistory = filterHistory ?? EMPTY_FILTER_HISTORY;
|
|
330
|
+
const pushdownFilters = replayFilterHistory(
|
|
331
|
+
request.pushdown_filters
|
|
332
|
+
? deserializeFilters(request.pushdown_filters, {
|
|
333
|
+
outputSchema: request.output_schema,
|
|
334
|
+
joinKeyBatches: request.join_keys,
|
|
335
|
+
extensionFunctions: meta.additionalFilterFunctions,
|
|
336
|
+
runtimeAlgorithms: meta.runtimeFilterAlgorithms,
|
|
337
|
+
evaluationContexts: meta.filterEvaluationContexts,
|
|
338
|
+
})
|
|
339
|
+
: undefined,
|
|
340
|
+
carriedFilterHistory,
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
// Create BoundStorage for cross-phase/cross-worker data sharing. Keyed
|
|
344
|
+
// per substream, not per process: every connection of a fanned-out
|
|
345
|
+
// scan shares this execution's storage, and one process may serve many
|
|
346
|
+
// of them (see BoundStorage.put). Rebuilt from the sealed init request
|
|
347
|
+
// on every HTTP continuation, so the key is the same on every tick.
|
|
329
348
|
const boundStorage = new BoundStorage(
|
|
330
349
|
defaultStorage,
|
|
331
350
|
response.execution_id ?? new Uint8Array(16),
|
|
351
|
+
request.substream_id ?? null,
|
|
332
352
|
);
|
|
333
353
|
|
|
334
354
|
const processParams: TableInOutProcessParams<TArgs> = {
|
|
@@ -382,12 +402,10 @@ export function defineTableInOutFunction<
|
|
|
382
402
|
) => {
|
|
383
403
|
if (!pState.state.materialized) {
|
|
384
404
|
const finalizeStates: TState[] = [];
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
//
|
|
389
|
-
// path reads from SQLite; HTTP path reads from CF DO / wherever
|
|
390
|
-
// the configured storage backend lives.
|
|
405
|
+
// Collect the states INPUT exchanges persisted -- the only
|
|
406
|
+
// source, on every transport: each INPUT turn upserts its
|
|
407
|
+
// substream's state here (SQLite, a CF DO, wherever the
|
|
408
|
+
// configured backend lives).
|
|
391
409
|
const stored = await boundStorage.collect();
|
|
392
410
|
for (const bytes of stored) {
|
|
393
411
|
finalizeStates.push(deserializeUserState(bytes) as TState);
|
|
@@ -467,11 +485,12 @@ export function defineTableInOutFunction<
|
|
|
467
485
|
return {
|
|
468
486
|
outputSchema,
|
|
469
487
|
inputSchema: request.bind_call.input_schema,
|
|
470
|
-
exchangeInit: () => ({ state, processParams }),
|
|
488
|
+
exchangeInit: () => ({ state, processParams, filterHistory: carriedFilterHistory }),
|
|
471
489
|
exchangeFn: async (
|
|
472
490
|
eState: {
|
|
473
491
|
state: TState;
|
|
474
492
|
processParams: TableInOutProcessParams<TArgs>;
|
|
493
|
+
filterHistory: FilterDeltaHistory;
|
|
475
494
|
},
|
|
476
495
|
input: VgiBatch,
|
|
477
496
|
out: OutputCollector
|
|
@@ -480,7 +499,7 @@ export function defineTableInOutFunction<
|
|
|
480
499
|
// custom metadata (attached by the C++ WriteInputBatch), so process()
|
|
481
500
|
// can answer a 0-row `notModified` batch instead of recomputing.
|
|
482
501
|
applyRevalidationValidators(eState.processParams, input);
|
|
483
|
-
applyDynamicFilterUpdate(eState
|
|
502
|
+
applyDynamicFilterUpdate(eState, input);
|
|
484
503
|
// Bake emit metadata onto the batch (0-row HTTP survival), then
|
|
485
504
|
// reconcile emitted batches to the (possibly projected) output schema
|
|
486
505
|
// by name — a process() may emit its full declared schema and let the
|
|
@@ -529,27 +548,23 @@ function applyRevalidationValidators(
|
|
|
529
548
|
params.ifModifiedSince = md?.get(CACHE_IF_MODIFIED_SINCE_KEY);
|
|
530
549
|
}
|
|
531
550
|
|
|
551
|
+
/**
|
|
552
|
+
* Apply the dynamic-filter delta an input batch carries (if any) and record it
|
|
553
|
+
* in the compacted history an HTTP cursor carries to the next exchange, so a
|
|
554
|
+
* tightened filter survives the turn boundary (the extension sends each delta
|
|
555
|
+
* once per stream). See `filter-pushdown/history.ts`.
|
|
556
|
+
*/
|
|
532
557
|
function applyDynamicFilterUpdate(
|
|
533
|
-
|
|
558
|
+
eState: { processParams: { pushdownFilters?: PushdownFilters }; filterHistory: FilterDeltaHistory },
|
|
534
559
|
input: VgiBatch,
|
|
535
560
|
): void {
|
|
536
561
|
const encoded = ((input as any)?.metadata as Map<string, string> | undefined)?.get("vgi_pushdown_filters");
|
|
537
562
|
if (!encoded) return;
|
|
538
|
-
|
|
539
|
-
if (new
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) {
|
|
544
|
-
throw new Error("dynamic filter metadata is not valid base64");
|
|
545
|
-
}
|
|
546
|
-
const binary = (globalThis as any).atob
|
|
547
|
-
? (globalThis as any).atob(encoded)
|
|
548
|
-
: Buffer.from(encoded, "base64").toString("binary");
|
|
549
|
-
const bytes = Uint8Array.from(binary, (character: string) => character.charCodeAt(0));
|
|
550
|
-
if (bytes.byteLength > (17 << 20)) throw new Error("dynamic filter IPC payload exceeds the encoded-size limit");
|
|
551
|
-
const current = params.pushdownFilters;
|
|
552
|
-
params.pushdownFilters = current.applyDelta(deserializeBatch(bytes));
|
|
563
|
+
const current = eState.processParams.pushdownFilters;
|
|
564
|
+
if (!current) throw new Error("dynamic filter delta received without an initial snapshot");
|
|
565
|
+
const recorded = recordFilterDelta(current, eState.filterHistory, decodeDynamicFilterMetadata(encoded));
|
|
566
|
+
eState.processParams.pushdownFilters = recorded.filters;
|
|
567
|
+
eState.filterHistory = recorded.history;
|
|
553
568
|
}
|
|
554
569
|
|
|
555
570
|
/**
|
|
@@ -725,14 +740,17 @@ export interface RowTransformConfig<TArgs = Record<string, any>> {
|
|
|
725
740
|
/**
|
|
726
741
|
* Positional args = the per-row INPUT COLUMNS (real typed args on the wire,
|
|
727
742
|
* no synthetic TABLE placeholder). Read from `batch` by declared name in
|
|
728
|
-
* process(); NOT surfaced on `params.args`.
|
|
743
|
+
* process(); NOT surfaced on `params.args`. A `Null` type declares the
|
|
744
|
+
* column ANY: the input column then arrives with whatever type DuckDB
|
|
745
|
+
* resolved for the call (see `params.bindCall.input_schema` in onBind).
|
|
729
746
|
*/
|
|
730
747
|
args?: Record<string, VgiDataType>;
|
|
731
748
|
/**
|
|
732
749
|
* Trailing VARARGS input columns: the per-row input is N columns of the
|
|
733
750
|
* declared type. A varargs blended function has no per-column declared
|
|
734
751
|
* names (the C++ bind names them col0..colN-1), so process() reads the
|
|
735
|
-
* columns POSITIONALLY off `batch`.
|
|
752
|
+
* columns POSITIONALLY off `batch`. A `Null` element type declares ANY
|
|
753
|
+
* varargs: each runtime column resolves its own type.
|
|
736
754
|
*/
|
|
737
755
|
varargs?: { name: string; type: VgiDataType; doc?: string };
|
|
738
756
|
/** Named (string-position) args stay bind-time scalars on `params.args`. */
|
|
@@ -805,20 +823,30 @@ export function defineRowTransformFunction<
|
|
|
805
823
|
let posIdx = 0;
|
|
806
824
|
if (config.args) {
|
|
807
825
|
for (const [name, type] of Object.entries(config.args)) {
|
|
826
|
+
// A Null arrow type declares the input column ANY (same convention as
|
|
827
|
+
// defineTableFunction / defineScalarFunction): advertised on the wire as
|
|
828
|
+
// vgi_type=any, and the client builds the input schema from the type
|
|
829
|
+
// DuckDB resolved for the call. Read the concrete type off
|
|
830
|
+
// bindCall.input_schema in onBind.
|
|
831
|
+
const isAny = isNull(type);
|
|
808
832
|
specs.push({
|
|
809
833
|
name,
|
|
810
834
|
position: posIdx++,
|
|
811
|
-
arrowType: type,
|
|
835
|
+
arrowType: isAny ? nullType() : type,
|
|
836
|
+
isAnyType: isAny,
|
|
812
837
|
doc: config.argDocs?.[name],
|
|
813
838
|
...constraintSpecFields(config.argConstraints?.[name]),
|
|
814
839
|
});
|
|
815
840
|
}
|
|
816
841
|
}
|
|
817
842
|
if (config.varargs) {
|
|
843
|
+
// ANY varargs: every runtime column resolves its own type.
|
|
844
|
+
const isAny = isNull(config.varargs.type);
|
|
818
845
|
specs.push({
|
|
819
846
|
name: config.varargs.name,
|
|
820
847
|
position: posIdx++,
|
|
821
|
-
arrowType: config.varargs.type,
|
|
848
|
+
arrowType: isAny ? nullType() : config.varargs.type,
|
|
849
|
+
isAnyType: isAny,
|
|
822
850
|
isVarargs: true,
|
|
823
851
|
doc: config.varargs.doc ?? config.argDocs?.[config.varargs.name],
|
|
824
852
|
...constraintSpecFields(config.argConstraints?.[config.varargs.name]),
|
|
@@ -919,7 +947,7 @@ export function defineRowTransformFunction<
|
|
|
919
947
|
createStreamHandlers(
|
|
920
948
|
request: InitRequest,
|
|
921
949
|
response: GlobalInitResponse,
|
|
922
|
-
|
|
950
|
+
filterHistory?: FilterDeltaHistory | null,
|
|
923
951
|
): StreamHandlers {
|
|
924
952
|
const args = extractArgs(request.bind_call);
|
|
925
953
|
const settings = batchToScalarDict(request.bind_call.settings);
|
|
@@ -932,19 +960,26 @@ export function defineRowTransformFunction<
|
|
|
932
960
|
? projectSchema(projIds, request.output_schema)
|
|
933
961
|
: request.output_schema;
|
|
934
962
|
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
963
|
+
// An HTTP continuation replays the dynamic-filter deltas its cursor
|
|
964
|
+
// carries, so the filters are the ones the previous exchange left.
|
|
965
|
+
const carriedFilterHistory = filterHistory ?? EMPTY_FILTER_HISTORY;
|
|
966
|
+
const pushdownFilters = replayFilterHistory(
|
|
967
|
+
request.pushdown_filters
|
|
968
|
+
? deserializeFilters(request.pushdown_filters, {
|
|
969
|
+
outputSchema: request.output_schema,
|
|
970
|
+
joinKeyBatches: request.join_keys,
|
|
971
|
+
extensionFunctions: meta.additionalFilterFunctions,
|
|
972
|
+
runtimeAlgorithms: meta.runtimeFilterAlgorithms,
|
|
973
|
+
evaluationContexts: meta.filterEvaluationContexts,
|
|
974
|
+
})
|
|
975
|
+
: undefined,
|
|
976
|
+
carriedFilterHistory,
|
|
977
|
+
);
|
|
944
978
|
|
|
945
979
|
const boundStorage = new BoundStorage(
|
|
946
980
|
defaultStorage,
|
|
947
981
|
response.execution_id ?? new Uint8Array(16),
|
|
982
|
+
request.substream_id ?? null,
|
|
948
983
|
);
|
|
949
984
|
|
|
950
985
|
const processParams: RowTransformProcessParams<TArgs> = {
|
|
@@ -986,14 +1021,18 @@ export function defineRowTransformFunction<
|
|
|
986
1021
|
return {
|
|
987
1022
|
outputSchema,
|
|
988
1023
|
inputSchema: request.bind_call.input_schema,
|
|
989
|
-
exchangeInit: () => ({ state: null, processParams }),
|
|
1024
|
+
exchangeInit: () => ({ state: null, processParams, filterHistory: carriedFilterHistory }),
|
|
990
1025
|
exchangeFn: async (
|
|
991
|
-
eState: {
|
|
1026
|
+
eState: {
|
|
1027
|
+
state: null;
|
|
1028
|
+
processParams: RowTransformProcessParams<TArgs>;
|
|
1029
|
+
filterHistory: FilterDeltaHistory;
|
|
1030
|
+
},
|
|
992
1031
|
input: VgiBatch,
|
|
993
1032
|
out: OutputCollector,
|
|
994
1033
|
) => {
|
|
995
1034
|
applyRevalidationValidators(eState.processParams, input);
|
|
996
|
-
applyDynamicFilterUpdate(eState
|
|
1035
|
+
applyDynamicFilterUpdate(eState, input);
|
|
997
1036
|
let wrappedOut: OutputCollector = makeMetadataBakingCollector(out);
|
|
998
1037
|
if (projIds) {
|
|
999
1038
|
wrappedOut = makeSchemaReconcilingCollector(wrappedOut, outputSchema);
|
package/src/functions/table.ts
CHANGED
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
isBatch,
|
|
10
10
|
isNull,
|
|
11
11
|
nullType,
|
|
12
|
-
deserializeBatch,
|
|
13
12
|
readCanonicalValue,
|
|
14
13
|
} from "../arrow/index.js";
|
|
15
14
|
import { codecFor } from "../arrow/codec/registry.js";
|
|
@@ -47,6 +46,13 @@ import {
|
|
|
47
46
|
FilteringOutputCollector,
|
|
48
47
|
type PushdownFilters,
|
|
49
48
|
} from "../filter-pushdown/index.js";
|
|
49
|
+
import {
|
|
50
|
+
decodeDynamicFilterMetadata,
|
|
51
|
+
EMPTY_FILTER_HISTORY,
|
|
52
|
+
type FilterDeltaHistory,
|
|
53
|
+
recordFilterDelta,
|
|
54
|
+
replayFilterHistory,
|
|
55
|
+
} from "../filter-pushdown/history.js";
|
|
50
56
|
import type { ColumnStatistics } from "../util/statistics.js";
|
|
51
57
|
import {
|
|
52
58
|
FunctionStability,
|
|
@@ -58,30 +64,6 @@ import {
|
|
|
58
64
|
import { BoundStorage, storage as globalStorage } from "./storage.js";
|
|
59
65
|
import { CACHE_IF_MODIFIED_SINCE_KEY, CACHE_IF_NONE_MATCH_KEY } from "../cache-control.js";
|
|
60
66
|
|
|
61
|
-
// Base64-decode a string into raw bytes. Used to unpack the dynamic filter
|
|
62
|
-
// update DuckDB attaches to each tick batch's custom metadata.
|
|
63
|
-
function base64Decode(s: string): Uint8Array {
|
|
64
|
-
if (new TextEncoder().encode(s).byteLength > (24 << 20)) {
|
|
65
|
-
throw new Error("base64 dynamic filter metadata exceeds the encoded-size limit");
|
|
66
|
-
}
|
|
67
|
-
if (s.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(s)) {
|
|
68
|
-
throw new Error("dynamic filter metadata is not valid base64");
|
|
69
|
-
}
|
|
70
|
-
const bin = (globalThis as any).atob ? (globalThis as any).atob(s) : Buffer.from(s, "base64").toString("binary");
|
|
71
|
-
const out = new Uint8Array(bin.length);
|
|
72
|
-
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
73
|
-
if (out.byteLength > (17 << 20)) throw new Error("dynamic filter IPC payload exceeds the encoded-size limit");
|
|
74
|
-
return out;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Read the first RecordBatch from an Arrow IPC stream buffer. Returns null if
|
|
78
|
-
// the stream has no batches.
|
|
79
|
-
function deserializeFilterBatch(bytes: Uint8Array): VgiBatch | null {
|
|
80
|
-
if (!bytes || bytes.length === 0) return null;
|
|
81
|
-
const batch = deserializeBatch(bytes);
|
|
82
|
-
return batch.numRows === 0 && batch.schema.fields.length === 0 ? null : batch;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
67
|
// Wrap an OutputCollector so each emitted RecordBatch is projected-by-name
|
|
86
68
|
// to the bound outputSchema before forwarding. Lenient: workers can emit
|
|
87
69
|
// over-wide batches (full declared schema) and the framework drops the
|
|
@@ -191,6 +173,17 @@ export interface TableProcessParams<TArgs = Record<string, any>> {
|
|
|
191
173
|
ifModifiedSince?: string;
|
|
192
174
|
}
|
|
193
175
|
|
|
176
|
+
/**
|
|
177
|
+
* A table stream's live handler state: the user's state, the params its
|
|
178
|
+
* process() sees, and the compacted dynamic-filter history an HTTP cursor
|
|
179
|
+
* carries to the next turn (see `filter-pushdown/history.ts`).
|
|
180
|
+
*/
|
|
181
|
+
interface TableHandlerState<TArgs, TState> {
|
|
182
|
+
state: TState;
|
|
183
|
+
processParams: TableProcessParams<TArgs>;
|
|
184
|
+
filterHistory: FilterDeltaHistory;
|
|
185
|
+
}
|
|
186
|
+
|
|
194
187
|
// ============================================================================
|
|
195
188
|
// Functional API
|
|
196
189
|
// ============================================================================
|
|
@@ -529,7 +522,8 @@ export function defineTableFunction<
|
|
|
529
522
|
|
|
530
523
|
createStreamHandlers(
|
|
531
524
|
request: InitRequest,
|
|
532
|
-
response: GlobalInitResponse
|
|
525
|
+
response: GlobalInitResponse,
|
|
526
|
+
filterHistory?: FilterDeltaHistory | null,
|
|
533
527
|
): StreamHandlers {
|
|
534
528
|
const args = extractArgs(request.bind_call);
|
|
535
529
|
const settings = batchToScalarDict(request.bind_call.settings);
|
|
@@ -553,9 +547,14 @@ export function defineTableFunction<
|
|
|
553
547
|
runtimeAlgorithms: meta.runtimeFilterAlgorithms,
|
|
554
548
|
evaluationContexts: meta.filterEvaluationContexts,
|
|
555
549
|
};
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
550
|
+
// An HTTP continuation replays the dynamic-filter deltas its cursor
|
|
551
|
+
// carries, so process() sees the filters the previous turn left, not the
|
|
552
|
+
// init snapshot (the extension sends each delta once per stream).
|
|
553
|
+
const carriedFilterHistory = filterHistory ?? EMPTY_FILTER_HISTORY;
|
|
554
|
+
const pushdownFilters = replayFilterHistory(
|
|
555
|
+
request.pushdown_filters ? deserializeFilters(request.pushdown_filters, filterOptions) : undefined,
|
|
556
|
+
carriedFilterHistory,
|
|
557
|
+
);
|
|
559
558
|
|
|
560
559
|
const boundStorage = new BoundStorage(globalStorage, response.execution_id);
|
|
561
560
|
|
|
@@ -593,9 +592,13 @@ export function defineTableFunction<
|
|
|
593
592
|
|
|
594
593
|
return {
|
|
595
594
|
outputSchema,
|
|
596
|
-
producerInit: () => ({
|
|
595
|
+
producerInit: (): TableHandlerState<TArgs, TState> => ({
|
|
596
|
+
state,
|
|
597
|
+
processParams,
|
|
598
|
+
filterHistory: carriedFilterHistory,
|
|
599
|
+
}),
|
|
597
600
|
onTick: (
|
|
598
|
-
pState:
|
|
601
|
+
pState: TableHandlerState<TArgs, TState>,
|
|
599
602
|
tickMetadata: Map<string, string> | undefined,
|
|
600
603
|
) => {
|
|
601
604
|
if (!tickMetadata) return;
|
|
@@ -613,21 +616,21 @@ export function defineTableFunction<
|
|
|
613
616
|
// Dynamic filter pushdown: DuckDB's Top-N optimizer tightens filters
|
|
614
617
|
// between ticks and serializes an atomic v2 delta into the tick
|
|
615
618
|
// batch's custom metadata under `vgi_pushdown_filters` (base64 of a
|
|
616
|
-
// filter IPC stream). Apply it to the prior
|
|
617
|
-
//
|
|
619
|
+
// filter IPC stream). Apply it to the prior state so process() sees
|
|
620
|
+
// the revised advisory predicate state, and record it in the
|
|
621
|
+
// compacted history an HTTP cursor carries to the next turn.
|
|
618
622
|
const encoded = tickMetadata.get("vgi_pushdown_filters");
|
|
619
623
|
if (!encoded) return;
|
|
620
624
|
const current = pState.processParams.pushdownFilters;
|
|
621
625
|
if (!current) throw new Error("dynamic filter delta received without an initial snapshot");
|
|
622
|
-
const bytes = base64Decode(encoded);
|
|
623
|
-
const filterBatch = deserializeFilterBatch(bytes);
|
|
624
|
-
if (!filterBatch) throw new Error("dynamic filter metadata must contain one RecordBatch");
|
|
625
626
|
// Assignment happens only after complete validation, so a rejected
|
|
626
627
|
// delta leaves the immutable prior state intact.
|
|
627
|
-
|
|
628
|
+
const recorded = recordFilterDelta(current, pState.filterHistory, decodeDynamicFilterMetadata(encoded));
|
|
629
|
+
pState.processParams.pushdownFilters = recorded.filters;
|
|
630
|
+
pState.filterHistory = recorded.history;
|
|
628
631
|
},
|
|
629
632
|
producerFn: async (
|
|
630
|
-
pState:
|
|
633
|
+
pState: TableHandlerState<TArgs, TState>,
|
|
631
634
|
out: OutputCollector
|
|
632
635
|
) => {
|
|
633
636
|
const current = pState.processParams.pushdownFilters;
|
package/src/functions/types.ts
CHANGED
|
@@ -26,6 +26,12 @@ import type { ArgumentSpec } from "../arguments/argument-spec.js";
|
|
|
26
26
|
export interface HandlerState<T = any> {
|
|
27
27
|
/** Serializable user state for HTTP exchange round-trips. */
|
|
28
28
|
state: T;
|
|
29
|
+
/**
|
|
30
|
+
* The compacted dynamic-filter history, for a stream that applies
|
|
31
|
+
* `vgi_pushdown_filters` deltas. Carried in the HTTP cursor so the next turn
|
|
32
|
+
* rebuilds the filters this one left.
|
|
33
|
+
*/
|
|
34
|
+
filterHistory?: import("../filter-pushdown/history.js").FilterDeltaHistory;
|
|
29
35
|
[key: string]: any;
|
|
30
36
|
}
|
|
31
37
|
|
|
@@ -229,10 +235,17 @@ export interface VgiFunction {
|
|
|
229
235
|
defaultOutputSchema?: VgiSchema;
|
|
230
236
|
bind(request: BindRequest): BindResponse | Promise<BindResponse>;
|
|
231
237
|
globalInit(request: InitRequest): GlobalInitResponse | Promise<GlobalInitResponse>;
|
|
238
|
+
/**
|
|
239
|
+
* Build the stream's handlers. On an HTTP continuation `filterHistory` is
|
|
240
|
+
* the compacted dynamic-filter history the cursor carried: a function that
|
|
241
|
+
* applies `vgi_pushdown_filters` deltas replays it onto the init snapshot and
|
|
242
|
+
* keeps the (updated) history on its handler state as `filterHistory`, which
|
|
243
|
+
* the dispatcher writes back into the next cursor.
|
|
244
|
+
*/
|
|
232
245
|
createStreamHandlers(
|
|
233
246
|
request: InitRequest,
|
|
234
247
|
response: GlobalInitResponse,
|
|
235
|
-
|
|
248
|
+
filterHistory?: import("../filter-pushdown/history.js").FilterDeltaHistory | null,
|
|
236
249
|
): StreamHandlers;
|
|
237
250
|
cardinality?(request: TableFunctionCardinalityRequest): TableCardinality | Promise<TableCardinality>;
|
|
238
251
|
/**
|
package/src/http/fetch.ts
CHANGED
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
import {
|
|
14
14
|
AuthContext,
|
|
15
15
|
createHttpHandler,
|
|
16
|
-
unpackStateToken,
|
|
17
16
|
type AuthenticateFn,
|
|
18
17
|
type DispatchHook,
|
|
19
18
|
type DispatchInfo,
|
|
@@ -24,13 +23,13 @@ import {
|
|
|
24
23
|
type Protocol,
|
|
25
24
|
} from "@query-farm/vgi-rpc";
|
|
26
25
|
import { arrowStateSerializer } from "../protocol/state-serializer.js";
|
|
27
|
-
import { buildVgiProtocol,
|
|
26
|
+
import { buildVgiProtocol, type ProtocolConfig } from "../protocol/dispatch.js";
|
|
28
27
|
import { createLandingRoutes, type LandingInfo } from "./landing.js";
|
|
29
28
|
import { setRequestAuthScope, type RequestAuthHolder } from "../request-auth.js";
|
|
30
29
|
|
|
31
30
|
export interface VgiFetchOptions {
|
|
32
31
|
/** Wire-protocol config (registry + catalogInterface). */
|
|
33
|
-
protocol:
|
|
32
|
+
protocol: ProtocolConfig;
|
|
34
33
|
/** HMAC key for state-token signing. Pass a stable, secret 32-byte key
|
|
35
34
|
* (e.g. derived from a Wrangler secret). Required because Workers don't
|
|
36
35
|
* preserve in-memory state across requests/instances. */
|
|
@@ -196,20 +195,6 @@ export function createVgiFetch(opts: VgiFetchOptions): (req: Request) => Promise
|
|
|
196
195
|
const protocol: Protocol = buildVgiProtocol({
|
|
197
196
|
signingKey: opts.signingKey,
|
|
198
197
|
...opts.protocol,
|
|
199
|
-
recoverExchangeState: async (opaqueData: Uint8Array) => {
|
|
200
|
-
const tokenString = new TextDecoder().decode(opaqueData);
|
|
201
|
-
// principal binding is enforced by the HTTP handler on the request that
|
|
202
|
-
// carried this token; the recovery path itself is not principal-scoped,
|
|
203
|
-
// so the scope names the protocol and leaves the identity anonymous.
|
|
204
|
-
// `protocol` is required since vgi-rpc 0.24.0: it is bound into the
|
|
205
|
-
// token's AEAD associated data, so a cursor minted under one hosted
|
|
206
|
-
// protocol cannot be opened under another.
|
|
207
|
-
const unpacked = await unpackStateToken(tokenString, opts.signingKey, tokenTtl, {
|
|
208
|
-
protocol: VGI_PROTOCOL_NAME,
|
|
209
|
-
principal: undefined,
|
|
210
|
-
});
|
|
211
|
-
return arrowStateSerializer.deserialize(unpacked.stateBytes);
|
|
212
|
-
},
|
|
213
198
|
});
|
|
214
199
|
|
|
215
200
|
const handler = createHttpHandler(protocol, {
|
package/src/index.core.ts
CHANGED