@query-farm/vgi 0.30.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/README.md +1 -1
- package/dist/catalog/item-encoding.d.ts +17 -0
- package/dist/catalog/read-only.d.ts +16 -0
- package/dist/client-entry.js +19 -1
- package/dist/client-entry.js.map +3 -3
- 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 +1280 -924
- package/dist/index.js.map +21 -17
- package/dist/protocol/carried-init-request.d.ts +28 -0
- package/dist/protocol/dispatch.d.ts +26 -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 +395 -188
- package/dist/serve-entry.js.map +16 -12
- package/dist/util/zstd-sync.d.ts +7 -0
- package/dist/worker-cf-entry.js +5047 -4710
- package/dist/worker-cf-entry.js.map +25 -21
- package/package.json +5 -4
- package/src/catalog/item-encoding.ts +59 -0
- package/src/catalog/read-only.ts +87 -19
- package/src/client/errors.ts +19 -0
- 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 +1 -9
- package/src/index.core.ts +1 -0
- package/src/protocol/carried-init-request.ts +139 -0
- package/src/protocol/dispatch.ts +28 -9
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@query-farm/vgi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "TypeScript framework for extending DuckDB with user-defined functions via the Vector Gateway Interface (VGI) over Arrow IPC.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"author": "Query Farm LLC (https://query.farm)",
|
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
"./src/index.ts",
|
|
35
35
|
"./src/client-entry.ts",
|
|
36
36
|
"./src/worker-cf-entry.ts",
|
|
37
|
-
"./src/serve-entry.ts"
|
|
37
|
+
"./src/serve-entry.ts",
|
|
38
|
+
"./src/arrow/impl-flechette/compat.ts"
|
|
38
39
|
],
|
|
39
40
|
"main": "dist/index.js",
|
|
40
41
|
"types": "dist/index.d.ts",
|
|
@@ -90,12 +91,12 @@
|
|
|
90
91
|
},
|
|
91
92
|
"peerDependencies": {
|
|
92
93
|
"@query-farm/apache-arrow": "^21.1.1",
|
|
93
|
-
"@query-farm/vgi-rpc": ">=0.
|
|
94
|
+
"@query-farm/vgi-rpc": ">=0.24.0 <0.25.0"
|
|
94
95
|
},
|
|
95
96
|
"devDependencies": {
|
|
96
97
|
"@duckdb/node-api": "^1.5.5-r.4",
|
|
97
98
|
"@query-farm/apache-arrow": "^21.1.1",
|
|
98
|
-
"@query-farm/vgi-rpc": "^0.
|
|
99
|
+
"@query-farm/vgi-rpc": "^0.24.0",
|
|
99
100
|
"@types/bun": "latest",
|
|
100
101
|
"typescript": "^7.0.2"
|
|
101
102
|
},
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Copyright 2025, 2026 Query Farm LLC - https://query.farm
|
|
2
|
+
// Catalog items that are built once and encoded once.
|
|
3
|
+
//
|
|
4
|
+
// A function listing from a descriptor-driven catalog depends only on static
|
|
5
|
+
// metadata, yet every catalog_schema_contents_functions request used to rebuild
|
|
6
|
+
// each FunctionInfo (argument and output schemas serialized to IPC, metadata
|
|
7
|
+
// resolved) and then Arrow-encode it again: ~1.5 ms per function, ~236 ms for
|
|
8
|
+
// the example worker's main schema, paid on every function-set load DuckDB
|
|
9
|
+
// makes. vgi-python fixed the same cost by caching the listing and storing each
|
|
10
|
+
// item's encoding on its (frozen dataclass) instance (6cc522a).
|
|
11
|
+
//
|
|
12
|
+
// JS objects are not frozen by default, and an encoding memo keyed by object
|
|
13
|
+
// identity is only sound for an object that cannot change after it was
|
|
14
|
+
// encoded. So the memo applies to FROZEN items only: a catalog opts in by
|
|
15
|
+
// returning items it froze with `freezeCatalogItem` (ReadOnlyCatalogInterface
|
|
16
|
+
// does), and every other item -- a catalog that builds fresh objects per call,
|
|
17
|
+
// or keeps and mutates its own -- is encoded on every call exactly as before.
|
|
18
|
+
|
|
19
|
+
import { encodeFunctionInfo, type FunctionInfo } from "../generated/vgi-client.js";
|
|
20
|
+
|
|
21
|
+
/** Encoded bytes per frozen item instance. Weak, so it never pins an item. */
|
|
22
|
+
const ENCODED = new WeakMap<object, Uint8Array>();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Deep-freeze a catalog item (plain objects and arrays, recursively) so it can
|
|
26
|
+
* be shared between requests and its encoding memoized.
|
|
27
|
+
*
|
|
28
|
+
* Binary fields (`Uint8Array` and other ArrayBuffer views) are left as they are
|
|
29
|
+
* -- a typed array with elements cannot be frozen -- and must be treated as
|
|
30
|
+
* read-only by convention, like the rest of a shared item.
|
|
31
|
+
*/
|
|
32
|
+
export function freezeCatalogItem<T extends object>(item: T): Readonly<T> {
|
|
33
|
+
deepFreeze(item);
|
|
34
|
+
return item;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function deepFreeze(value: unknown): void {
|
|
38
|
+
if (value === null || typeof value !== "object" || Object.isFrozen(value) || ArrayBuffer.isView(value)) return;
|
|
39
|
+
const proto = Object.getPrototypeOf(value);
|
|
40
|
+
if (proto !== Object.prototype && proto !== Array.prototype && proto !== null) return;
|
|
41
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
42
|
+
Object.freeze(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Arrow-encode a FunctionInfo, once per frozen instance.
|
|
47
|
+
*
|
|
48
|
+
* The bytes returned for a frozen item are shared between calls; callers copy
|
|
49
|
+
* them into a response and never write to them.
|
|
50
|
+
*/
|
|
51
|
+
export function encodeFunctionInfoOnce(info: FunctionInfo): Uint8Array {
|
|
52
|
+
if (!Object.isFrozen(info)) return encodeFunctionInfo(info);
|
|
53
|
+
let encoded = ENCODED.get(info);
|
|
54
|
+
if (encoded === undefined) {
|
|
55
|
+
encoded = encodeFunctionInfo(info);
|
|
56
|
+
ENCODED.set(info, encoded);
|
|
57
|
+
}
|
|
58
|
+
return encoded;
|
|
59
|
+
}
|
package/src/catalog/read-only.ts
CHANGED
|
@@ -18,7 +18,6 @@ import {
|
|
|
18
18
|
type CopyFromFormatInfo,
|
|
19
19
|
encodeAttachCatalogInfo,
|
|
20
20
|
encodeScanFunctionResult,
|
|
21
|
-
encodeFunctionInfo,
|
|
22
21
|
} from "./interface.js";
|
|
23
22
|
import { schemaDescriptorPath, type CatalogDescriptor, type SchemaDescriptor, type TableDescriptor, type ViewDescriptor, type MacroDescriptor, type SettingDescriptor, type SecretTypeDescriptor, type ForeignKeyDef, type DefaultValue } from "./descriptors.js";
|
|
24
23
|
import { serializeColumnStatistics } from "../util/statistics.js";
|
|
@@ -32,12 +31,24 @@ import { FunctionStability, NullHandling, OrderPreservation, DEFAULT_MAX_WORKERS
|
|
|
32
31
|
import { Arguments } from "../arguments/arguments.js";
|
|
33
32
|
import { normalizeSchemaPath, schemaPathDisplay, schemaPathsEqual } from "../schema-path.js";
|
|
34
33
|
import { ForeignKeyInfoSchema } from "../generated/vgi-protocol-schemas.js";
|
|
34
|
+
import { encodeFunctionInfoOnce, freezeCatalogItem } from "./item-encoding.js";
|
|
35
35
|
|
|
36
36
|
export class ReadOnlyCatalogInterface extends CatalogInterface {
|
|
37
37
|
private _descriptor: CatalogDescriptor;
|
|
38
38
|
private _registry: FunctionRegistry;
|
|
39
39
|
private _attachments = new Map<string, AttachOpaqueData>();
|
|
40
40
|
private _version = 1;
|
|
41
|
+
/**
|
|
42
|
+
* Function listings by schema and listing type, built on first request. A
|
|
43
|
+
* listing depends only on the registered functions' static metadata, so it
|
|
44
|
+
* is built once and every request returns the same frozen FunctionInfo
|
|
45
|
+
* instances -- which the listing handler then encodes once each (see
|
|
46
|
+
* item-encoding.ts). The descriptor is treated as immutable after the first
|
|
47
|
+
* listing, as the rest of this class already does.
|
|
48
|
+
*/
|
|
49
|
+
private _functionListings = new Map<SchemaDescriptor, Map<FunctionListingKind, readonly FunctionInfo[]>>();
|
|
50
|
+
/** One frozen FunctionInfo per (declaring schema, function), shared by listings and `global_functions`. */
|
|
51
|
+
private _functionInfos = new Map<SchemaDescriptor, Map<VgiFunction, FunctionInfo>>();
|
|
41
52
|
|
|
42
53
|
constructor(descriptor: CatalogDescriptor, registry: FunctionRegistry) {
|
|
43
54
|
super();
|
|
@@ -169,7 +180,7 @@ export class ReadOnlyCatalogInterface extends CatalogInterface {
|
|
|
169
180
|
// Functions the client should ALSO publish into its global (system.main)
|
|
170
181
|
// namespace, under `global_function_prefix` + "_" + name. Empty unless
|
|
171
182
|
// the descriptor opts in via `globalFunctions`.
|
|
172
|
-
global_functions: this._globalFunctionInfos().map(
|
|
183
|
+
global_functions: this._globalFunctionInfos().map(encodeFunctionInfoOnce),
|
|
173
184
|
global_function_prefix: this._descriptor.globalFunctionPrefix ?? "",
|
|
174
185
|
resolved_data_version: null,
|
|
175
186
|
resolved_implementation_version: null,
|
|
@@ -412,18 +423,53 @@ export class ReadOnlyCatalogInterface extends CatalogInterface {
|
|
|
412
423
|
const schema = this._descriptor.schemas.find((s) => schemaPathsEqual(schemaDescriptorPath(s), path));
|
|
413
424
|
if (!schema || !schema.functions) return [];
|
|
414
425
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
426
|
+
// Filter by SchemaObjectType (lowercase on the wire: scalar_function,
|
|
427
|
+
// table_function, aggregate_function). Unknown filter values pass
|
|
428
|
+
// everything through. Defensive String() coerces null/undefined.
|
|
429
|
+
const kind = functionListingKind(type);
|
|
430
|
+
const canonicalPath = schemaDescriptorPath(schema);
|
|
431
|
+
if (!sameSpelling(path, canonicalPath)) {
|
|
432
|
+
// Schema names match case-insensitively, and an item's schema_path keeps
|
|
433
|
+
// the caller's spelling, so a differently-spelled request is built fresh,
|
|
434
|
+
// exactly as before, rather than growing the cache per spelling.
|
|
435
|
+
return schema.functions.filter((f) => inListing(f, kind)).map((f) => this._functionToInfo(f, path));
|
|
436
|
+
}
|
|
437
|
+
let listings = this._functionListings.get(schema);
|
|
438
|
+
if (!listings) {
|
|
439
|
+
listings = new Map();
|
|
440
|
+
this._functionListings.set(schema, listings);
|
|
441
|
+
}
|
|
442
|
+
let listing = listings.get(kind);
|
|
443
|
+
if (!listing) {
|
|
444
|
+
listing = Object.freeze(
|
|
445
|
+
schema.functions.filter((f) => inListing(f, kind)).map((f) => this._sharedFunctionInfo(schema, f)),
|
|
446
|
+
);
|
|
447
|
+
listings.set(kind, listing);
|
|
448
|
+
}
|
|
449
|
+
// A fresh array of the shared (frozen) items: a caller may reorder or
|
|
450
|
+
// extend the array it gets without touching the cache.
|
|
451
|
+
return listing.slice();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The frozen FunctionInfo for `f` as declared in `schema`, built on first use.
|
|
456
|
+
* Shared between requests, so it must not (and, frozen, cannot) be mutated.
|
|
457
|
+
*/
|
|
458
|
+
private _sharedFunctionInfo(schema: SchemaDescriptor, f: VgiFunction): FunctionInfo {
|
|
459
|
+
let infos = this._functionInfos.get(schema);
|
|
460
|
+
if (!infos) {
|
|
461
|
+
infos = new Map();
|
|
462
|
+
this._functionInfos.set(schema, infos);
|
|
463
|
+
}
|
|
464
|
+
let info = infos.get(f);
|
|
465
|
+
if (!info) {
|
|
466
|
+
// Cloned before freezing: the built item shares arrays with the function's
|
|
467
|
+
// own metadata (categories, monotonicity, ...), which must stay the
|
|
468
|
+
// author's to do with as they like.
|
|
469
|
+
info = freezeCatalogItem(structuredClone(this._functionToInfo(f, schemaDescriptorPath(schema))));
|
|
470
|
+
infos.set(f, info);
|
|
471
|
+
}
|
|
472
|
+
return info;
|
|
427
473
|
}
|
|
428
474
|
|
|
429
475
|
/**
|
|
@@ -602,22 +648,22 @@ export class ReadOnlyCatalogInterface extends CatalogInterface {
|
|
|
602
648
|
const globals = this._descriptor.globalFunctions ?? [];
|
|
603
649
|
if (globals.length === 0) return [];
|
|
604
650
|
|
|
605
|
-
const schemaOf = new Map<VgiFunction,
|
|
651
|
+
const schemaOf = new Map<VgiFunction, SchemaDescriptor>();
|
|
606
652
|
for (const sch of this._descriptor.schemas) {
|
|
607
653
|
for (const fn of sch.functions ?? []) {
|
|
608
|
-
if (!schemaOf.has(fn)) schemaOf.set(fn,
|
|
654
|
+
if (!schemaOf.has(fn)) schemaOf.set(fn, sch);
|
|
609
655
|
}
|
|
610
656
|
}
|
|
611
657
|
|
|
612
658
|
return globals.map((fn) => {
|
|
613
|
-
const
|
|
614
|
-
if (
|
|
659
|
+
const schema = schemaOf.get(fn);
|
|
660
|
+
if (schema === undefined) {
|
|
615
661
|
throw new Error(
|
|
616
662
|
`Catalog '${this._descriptor.name}': '${fn.meta.name}' is listed in ` +
|
|
617
663
|
`globalFunctions but is not declared in any schema.`,
|
|
618
664
|
);
|
|
619
665
|
}
|
|
620
|
-
return this.
|
|
666
|
+
return this._sharedFunctionInfo(schema, fn);
|
|
621
667
|
});
|
|
622
668
|
}
|
|
623
669
|
|
|
@@ -1066,3 +1112,25 @@ function inlineScanFunction(
|
|
|
1066
1112
|
const argBytes = serializeArgsBatch(args ?? new Arguments(), argSchema);
|
|
1067
1113
|
return encodeScanFunctionResult(func.meta.name, argBytes, [], schemaPath);
|
|
1068
1114
|
}
|
|
1115
|
+
|
|
1116
|
+
/** Which functions a catalog_schema_contents_functions listing keeps. */
|
|
1117
|
+
type FunctionListingKind = "SCALAR_FUNCTION" | "TABLE_FUNCTION" | "AGGREGATE_FUNCTION" | "ALL";
|
|
1118
|
+
|
|
1119
|
+
function functionListingKind(type: unknown): FunctionListingKind {
|
|
1120
|
+
const t = String(type ?? "").toUpperCase();
|
|
1121
|
+
return t === "SCALAR_FUNCTION" || t === "TABLE_FUNCTION" || t === "AGGREGATE_FUNCTION" ? t : "ALL";
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function inListing(f: VgiFunction, kind: FunctionListingKind): boolean {
|
|
1125
|
+
if (kind === "SCALAR_FUNCTION") return f.kind === "scalar";
|
|
1126
|
+
if (kind === "TABLE_FUNCTION") return f.kind === "table" || f.kind === "table_in_out" || (f.kind as string) === "table_buffering";
|
|
1127
|
+
if (kind === "AGGREGATE_FUNCTION") return (f.kind as string) === "aggregate";
|
|
1128
|
+
return true;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/** Whether two schema paths are spelled identically (not just equal case-insensitively). */
|
|
1132
|
+
function sameSpelling(left: Iterable<unknown> | string, right: Iterable<unknown> | string): boolean {
|
|
1133
|
+
const a = normalizeSchemaPath(left);
|
|
1134
|
+
const b = normalizeSchemaPath(right);
|
|
1135
|
+
return a.length === b.length && a.every((part, i) => part === b[i]);
|
|
1136
|
+
}
|
package/src/client/errors.ts
CHANGED
|
@@ -63,6 +63,25 @@ export function wrapRpcWithErrorEnrichment(rpc: RpcClient): RpcClient {
|
|
|
63
63
|
throw e;
|
|
64
64
|
}
|
|
65
65
|
},
|
|
66
|
+
// The batch-level twins (vgi-rpc 0.24.0) get the same treatment: a caller
|
|
67
|
+
// holding encoded Arrow should still see the worker's traceback rather
|
|
68
|
+
// than a bare RpcError.
|
|
69
|
+
async callRaw(method, input) {
|
|
70
|
+
try {
|
|
71
|
+
return await rpc.callRaw(method, input);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
if (e instanceof RpcError) throw VgiClientError.fromRpcError(e);
|
|
74
|
+
throw e;
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
async streamRaw(method, input, options) {
|
|
78
|
+
try {
|
|
79
|
+
return await rpc.streamRaw(method, input, options);
|
|
80
|
+
} catch (e) {
|
|
81
|
+
if (e instanceof RpcError) throw VgiClientError.fromRpcError(e);
|
|
82
|
+
throw e;
|
|
83
|
+
}
|
|
84
|
+
},
|
|
66
85
|
describe: () => rpc.describe(),
|
|
67
86
|
close: () => rpc.close(),
|
|
68
87
|
};
|
|
@@ -22,9 +22,33 @@ import {
|
|
|
22
22
|
|
|
23
23
|
type SqlBoolean = boolean | null;
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
/**
|
|
26
|
+
* A batch's columns by name, resolved once per batch.
|
|
27
|
+
*
|
|
28
|
+
* Filters are evaluated row by row, and resolving a `column_ref` per row --
|
|
29
|
+
* a field search plus `getChildAt`, which builds a fresh Vector on arrow-js --
|
|
30
|
+
* cost more than the comparison itself: ~0.3 ms per 1000-row batch per
|
|
31
|
+
* referenced column. A batch is immutable, so what it resolves to never changes.
|
|
32
|
+
*/
|
|
33
|
+
const RESOLVED_COLUMNS = new WeakMap<object, Map<string, { type: VgiDataType; column: unknown } | null>>();
|
|
34
|
+
|
|
35
|
+
function resolveColumn(batch: VgiBatch, columnName: string): { type: VgiDataType; column: unknown } | null {
|
|
36
|
+
let byName = RESOLVED_COLUMNS.get(batch);
|
|
37
|
+
if (!byName) {
|
|
38
|
+
byName = new Map();
|
|
39
|
+
RESOLVED_COLUMNS.set(batch, byName);
|
|
40
|
+
}
|
|
41
|
+
let resolved = byName.get(columnName);
|
|
42
|
+
if (resolved === undefined) {
|
|
43
|
+
const index = batch.schema.fields.findIndex((field) => field.name === columnName);
|
|
44
|
+
if (index < 0) {
|
|
45
|
+
throw new FilterV2Error(`filter column ${columnName} is unavailable in emitted batch`);
|
|
46
|
+
}
|
|
47
|
+
const column = batch.getChildAt(index);
|
|
48
|
+
resolved = column ? { type: batch.schema.fields[index].type, column } : null;
|
|
49
|
+
byName.set(columnName, resolved);
|
|
50
|
+
}
|
|
51
|
+
return resolved;
|
|
28
52
|
}
|
|
29
53
|
|
|
30
54
|
function deepEqual(left: unknown, right: unknown): boolean {
|
|
@@ -307,11 +331,8 @@ function evaluateCall(expression: Extract<FilterExpression, { node: "call" }>, a
|
|
|
307
331
|
function evaluateExpression(expression: FilterExpression, batch: VgiBatch, row: number, context: EvaluationContext): unknown {
|
|
308
332
|
switch (expression.node) {
|
|
309
333
|
case "column_ref": {
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
throw new FilterV2Error(`filter column ${expression.columnName} is unavailable in emitted batch`);
|
|
313
|
-
}
|
|
314
|
-
return readCell(batch, projectedIndex, row);
|
|
334
|
+
const resolved = resolveColumn(batch, expression.columnName);
|
|
335
|
+
return resolved ? readCanonicalValue(resolved.type, resolved.column as any, row) : null;
|
|
315
336
|
}
|
|
316
337
|
case "field_ref": {
|
|
317
338
|
const parent = evaluateExpression(expression.expression, batch, row, context);
|
|
@@ -411,6 +432,32 @@ export class PushdownFilters {
|
|
|
411
432
|
return applyFilterDelta(this, batch);
|
|
412
433
|
}
|
|
413
434
|
|
|
435
|
+
/**
|
|
436
|
+
* These filters with their predicates arranged in `order`.
|
|
437
|
+
*
|
|
438
|
+
* Only the order changes, and `order` must name exactly the live predicate
|
|
439
|
+
* ids. Restores the order a stream had before its delta history was
|
|
440
|
+
* compacted: an id removed and later re-added moves to the end, which a
|
|
441
|
+
* shorter replay does not reproduce by itself.
|
|
442
|
+
*/
|
|
443
|
+
withPredicateOrder(order: readonly string[]): PushdownFilters {
|
|
444
|
+
const predicates = this.predicates;
|
|
445
|
+
if (predicates.length === order.length && predicates.every((predicate, i) => predicate.id === order[i])) {
|
|
446
|
+
return this;
|
|
447
|
+
}
|
|
448
|
+
const byId = new Map(predicates.map((predicate) => [predicate.id, predicate]));
|
|
449
|
+
if (order.length !== byId.size || new Set(order).size !== order.length || !order.every((id) => byId.has(id))) {
|
|
450
|
+
throw new FilterV2Error("recorded predicate order does not match the replayed filter state");
|
|
451
|
+
}
|
|
452
|
+
return new PushdownFilters(
|
|
453
|
+
order.map((id) => byId.get(id)!),
|
|
454
|
+
this.evaluationContext,
|
|
455
|
+
this.options,
|
|
456
|
+
new Map(this.revisions),
|
|
457
|
+
new Set(this.requiredIds),
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
414
461
|
evaluate(batch: VgiBatch): Uint8Array {
|
|
415
462
|
const mask = new Uint8Array(batch.numRows);
|
|
416
463
|
mask.fill(1);
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// Copyright 2025, 2026 Query Farm LLC - https://query.farm
|
|
2
|
+
// Dynamic-filter state across HTTP turns: a compacted delta history.
|
|
3
|
+
//
|
|
4
|
+
// A table stream's filters can change mid-scan. DuckDB's Top-N operator (and
|
|
5
|
+
// friends) tightens a bound as rows arrive and ships the change as a v2 *delta*
|
|
6
|
+
// on the next tick's `vgi_pushdown_filters` metadata -- and only then: the
|
|
7
|
+
// extension sends each delta ONCE per stream, when a filter's value changes, not
|
|
8
|
+
// on every tick. A byte-stream transport keeps the parsed filters in memory
|
|
9
|
+
// between ticks. An HTTP stream cannot: every turn rebuilds its handlers from the
|
|
10
|
+
// state token, so whatever a delta changed has to ride that token or it is lost
|
|
11
|
+
// on the next turn, and the worker quietly falls back to the init snapshot.
|
|
12
|
+
//
|
|
13
|
+
// Carrying every delta ever received would be the opposite bug: the token grows
|
|
14
|
+
// by a delta per tick and each turn replays all of them, quadratic in the tick
|
|
15
|
+
// count (vgi-python 0283898). So the token carries a COMPACTED history -- for
|
|
16
|
+
// each (id, revision) of the live state, tombstones included, the first delta
|
|
17
|
+
// that carried it. Replaying just those reproduces the same predicates, values
|
|
18
|
+
// and revisions: an earlier update to an id is overwritten by its current
|
|
19
|
+
// revision, and a later one was stale when it arrived and is stale again on
|
|
20
|
+
// replay. The history is therefore bounded by the number of predicate ids, not
|
|
21
|
+
// the number of ticks. Replay cannot always reproduce predicate ORDER (an id
|
|
22
|
+
// removed and later re-added moves to the end), so the live order is recorded
|
|
23
|
+
// alongside and restored after replay.
|
|
24
|
+
|
|
25
|
+
import { type VgiBatch, deserializeBatch, readCanonicalValue } from "../arrow/index.js";
|
|
26
|
+
import { FilterV2Error } from "./deserialize.js";
|
|
27
|
+
import type { PushdownFilters } from "./evaluate.js";
|
|
28
|
+
|
|
29
|
+
/** Encoded-size limit on the base64 `vgi_pushdown_filters` value. */
|
|
30
|
+
const MAX_ENCODED_DELTA_BYTES = 24 << 20;
|
|
31
|
+
/** Decoded-size limit on one delta's Arrow IPC payload. */
|
|
32
|
+
const MAX_DELTA_IPC_BYTES = 17 << 20;
|
|
33
|
+
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What an HTTP stream carries between turns to rebuild its dynamic filters.
|
|
37
|
+
*
|
|
38
|
+
* Both halves are plain data so the state serializer can put them in the
|
|
39
|
+
* cursor, and both are bounded by the number of predicate ids the scan has
|
|
40
|
+
* seen (see {@link recordFilterDelta}).
|
|
41
|
+
*/
|
|
42
|
+
export interface FilterDeltaHistory {
|
|
43
|
+
/** Arrow IPC bytes of each delta still needed, in arrival order. */
|
|
44
|
+
readonly deltas: readonly Uint8Array[];
|
|
45
|
+
/** The live predicate ids, in the order the stream had them. */
|
|
46
|
+
readonly order: readonly string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A stream that has applied no delta. */
|
|
50
|
+
export const EMPTY_FILTER_HISTORY: FilterDeltaHistory = Object.freeze({
|
|
51
|
+
deltas: Object.freeze([]) as readonly Uint8Array[],
|
|
52
|
+
order: Object.freeze([]) as readonly string[],
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Decode the base64 `vgi_pushdown_filters` metadata value into delta IPC bytes.
|
|
57
|
+
*
|
|
58
|
+
* Validates the encoding strictly and enforces the encoded and decoded size
|
|
59
|
+
* limits before anything is parsed.
|
|
60
|
+
*/
|
|
61
|
+
export function decodeDynamicFilterMetadata(encoded: string): Uint8Array {
|
|
62
|
+
if (new TextEncoder().encode(encoded).byteLength > MAX_ENCODED_DELTA_BYTES) {
|
|
63
|
+
throw new Error("base64 dynamic filter metadata exceeds the encoded-size limit");
|
|
64
|
+
}
|
|
65
|
+
if (encoded.length % 4 !== 0 || !BASE64.test(encoded)) {
|
|
66
|
+
throw new Error("dynamic filter metadata is not valid base64");
|
|
67
|
+
}
|
|
68
|
+
const binary = (globalThis as any).atob
|
|
69
|
+
? (globalThis as any).atob(encoded)
|
|
70
|
+
: Buffer.from(encoded, "base64").toString("binary");
|
|
71
|
+
const bytes = new Uint8Array(binary.length);
|
|
72
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
73
|
+
if (bytes.byteLength > MAX_DELTA_IPC_BYTES) {
|
|
74
|
+
throw new Error("dynamic filter IPC payload exceeds the encoded-size limit");
|
|
75
|
+
}
|
|
76
|
+
return bytes;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Read one delta's IPC bytes into its single document batch. */
|
|
80
|
+
export function decodeFilterDelta(bytes: Uint8Array): VgiBatch {
|
|
81
|
+
if (bytes.byteLength > MAX_DELTA_IPC_BYTES) {
|
|
82
|
+
throw new Error("dynamic filter IPC payload exceeds the encoded-size limit");
|
|
83
|
+
}
|
|
84
|
+
const batch = bytes.byteLength > 0 ? deserializeBatch(bytes) : null;
|
|
85
|
+
if (!batch || (batch.numRows === 0 && batch.schema.fields.length === 0)) {
|
|
86
|
+
throw new Error("dynamic filter metadata must contain one RecordBatch");
|
|
87
|
+
}
|
|
88
|
+
return batch as VgiBatch;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The `(id, revision)` of every update a delta document carries, as
|
|
93
|
+
* `id\0revision` keys.
|
|
94
|
+
*
|
|
95
|
+
* A structural read only, for bookkeeping over deltas that have already been
|
|
96
|
+
* applied -- and therefore validated -- by {@link PushdownFilters.applyDelta}.
|
|
97
|
+
* It neither applies nor validates anything.
|
|
98
|
+
*/
|
|
99
|
+
function deltaRevisionKeys(batch: VgiBatch): string[] {
|
|
100
|
+
const column = batch.getChildAt(0);
|
|
101
|
+
if (!column) throw new FilterV2Error("delta document has no filter_spec column");
|
|
102
|
+
const raw = readCanonicalValue(batch.schema.fields[0].type, column, 0);
|
|
103
|
+
const document = JSON.parse(String(raw)) as { updates?: Array<{ id: string; revision: number }> };
|
|
104
|
+
return (document.updates ?? []).map((update) => revisionKey(update.id, update.revision));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function revisionKey(id: string, revision: number): string {
|
|
108
|
+
return `${id}\u0000${revision}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Apply one tick's delta and return the new filters plus the history a cursor
|
|
113
|
+
* must carry to rebuild them.
|
|
114
|
+
*
|
|
115
|
+
* The history is compacted to the deltas that installed some predicate's
|
|
116
|
+
* CURRENT revision, tombstones included: for each `(id, revision)` of the live
|
|
117
|
+
* state, the first delta (in arrival order) carrying it. That delta is the one
|
|
118
|
+
* that was applied -- any later delta carrying the same revision was stale on
|
|
119
|
+
* arrival -- and a delta none of whose updates is current can never matter
|
|
120
|
+
* again, because revisions only move forward.
|
|
121
|
+
*
|
|
122
|
+
* @param current - The stream's filters before this delta.
|
|
123
|
+
* @param history - The compacted history those filters were rebuilt from.
|
|
124
|
+
* @param deltaBytes - The IPC bytes of the delta this tick carried.
|
|
125
|
+
*/
|
|
126
|
+
export function recordFilterDelta(
|
|
127
|
+
current: PushdownFilters,
|
|
128
|
+
history: FilterDeltaHistory,
|
|
129
|
+
deltaBytes: Uint8Array,
|
|
130
|
+
): { filters: PushdownFilters; history: FilterDeltaHistory } {
|
|
131
|
+
const incoming = decodeFilterDelta(deltaBytes);
|
|
132
|
+
// Assignment happens only after complete validation, so a rejected delta
|
|
133
|
+
// leaves the caller's prior state (and history) intact.
|
|
134
|
+
const filters = current.applyDelta(incoming);
|
|
135
|
+
const wanted = new Set<string>();
|
|
136
|
+
for (const [id, revision] of filters.revisions) wanted.add(revisionKey(id, revision));
|
|
137
|
+
const deltas: Uint8Array[] = [];
|
|
138
|
+
const consider = (bytes: Uint8Array, batch: VgiBatch): void => {
|
|
139
|
+
let carries = false;
|
|
140
|
+
for (const key of deltaRevisionKeys(batch)) {
|
|
141
|
+
if (wanted.delete(key)) carries = true;
|
|
142
|
+
}
|
|
143
|
+
if (carries) deltas.push(bytes);
|
|
144
|
+
};
|
|
145
|
+
for (const bytes of history.deltas) consider(bytes, decodeFilterDelta(bytes));
|
|
146
|
+
consider(deltaBytes, incoming);
|
|
147
|
+
return { filters, history: { deltas, order: filters.predicates.map((predicate) => predicate.id) } };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const HISTORY_FORMAT_VERSION = 1;
|
|
151
|
+
const UTF8_ENCODER = new TextEncoder();
|
|
152
|
+
const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Encode a history for the state token, or `null` when there is nothing to
|
|
156
|
+
* carry (no delta applied, which is every stream without dynamic filters).
|
|
157
|
+
*
|
|
158
|
+
* Layout (little-endian): `u8 version | u32 n | n x (u32 len, delta IPC) |
|
|
159
|
+
* u32 m | m x (u32 len, utf8 id)`.
|
|
160
|
+
*/
|
|
161
|
+
export function encodeFilterHistory(history: FilterDeltaHistory | null | undefined): Uint8Array | null {
|
|
162
|
+
if (!history || history.deltas.length === 0) return null;
|
|
163
|
+
const ids = history.order.map((id) => UTF8_ENCODER.encode(id));
|
|
164
|
+
let size = 1 + 4 + 4;
|
|
165
|
+
for (const delta of history.deltas) size += 4 + delta.byteLength;
|
|
166
|
+
for (const id of ids) size += 4 + id.byteLength;
|
|
167
|
+
const out = new Uint8Array(size);
|
|
168
|
+
const view = new DataView(out.buffer);
|
|
169
|
+
let offset = 0;
|
|
170
|
+
out[offset++] = HISTORY_FORMAT_VERSION;
|
|
171
|
+
const put = (chunks: readonly Uint8Array[]): void => {
|
|
172
|
+
view.setUint32(offset, chunks.length, true);
|
|
173
|
+
offset += 4;
|
|
174
|
+
for (const chunk of chunks) {
|
|
175
|
+
view.setUint32(offset, chunk.byteLength, true);
|
|
176
|
+
offset += 4;
|
|
177
|
+
out.set(chunk, offset);
|
|
178
|
+
offset += chunk.byteLength;
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
put(history.deltas);
|
|
182
|
+
put(ids);
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Decode {@link encodeFilterHistory}'s bytes; `null`/empty is the empty history. */
|
|
187
|
+
export function decodeFilterHistory(bytes: Uint8Array | null | undefined): FilterDeltaHistory {
|
|
188
|
+
if (!bytes || bytes.byteLength === 0) return EMPTY_FILTER_HISTORY;
|
|
189
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
190
|
+
let offset = 0;
|
|
191
|
+
const need = (n: number): void => {
|
|
192
|
+
if (offset + n > bytes.byteLength) throw new Error("dynamic filter history in the state token is truncated");
|
|
193
|
+
};
|
|
194
|
+
need(1);
|
|
195
|
+
if (bytes[offset++] !== HISTORY_FORMAT_VERSION) {
|
|
196
|
+
throw new Error("dynamic filter history in the state token has an unknown format");
|
|
197
|
+
}
|
|
198
|
+
const take = (): Uint8Array[] => {
|
|
199
|
+
need(4);
|
|
200
|
+
const count = view.getUint32(offset, true);
|
|
201
|
+
offset += 4;
|
|
202
|
+
const chunks: Uint8Array[] = [];
|
|
203
|
+
for (let i = 0; i < count; i++) {
|
|
204
|
+
need(4);
|
|
205
|
+
const length = view.getUint32(offset, true);
|
|
206
|
+
offset += 4;
|
|
207
|
+
need(length);
|
|
208
|
+
// A copy, not a view: Arrow readers want their own aligned buffer.
|
|
209
|
+
chunks.push(bytes.slice(offset, offset + length));
|
|
210
|
+
offset += length;
|
|
211
|
+
}
|
|
212
|
+
return chunks;
|
|
213
|
+
};
|
|
214
|
+
const deltas = take();
|
|
215
|
+
const order = take().map((id) => UTF8_DECODER.decode(id));
|
|
216
|
+
if (offset !== bytes.byteLength) throw new Error("dynamic filter history in the state token has trailing bytes");
|
|
217
|
+
return { deltas, order };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Rebuild a turn's filters from the init snapshot plus a carried history.
|
|
222
|
+
*
|
|
223
|
+
* Every delta here was validated when it first arrived and has ridden a
|
|
224
|
+
* worker-sealed token since, but it is re-applied through the same strict
|
|
225
|
+
* parser -- a TS delta parse is structural, with no bind to skip.
|
|
226
|
+
*
|
|
227
|
+
* @param filters - The filters parsed from the init request's snapshot.
|
|
228
|
+
* @param history - The history {@link recordFilterDelta} produced.
|
|
229
|
+
*/
|
|
230
|
+
export function replayFilterHistory(
|
|
231
|
+
filters: PushdownFilters | undefined,
|
|
232
|
+
history: FilterDeltaHistory | null | undefined,
|
|
233
|
+
): PushdownFilters | undefined {
|
|
234
|
+
if (!history || history.deltas.length === 0) return filters;
|
|
235
|
+
if (!filters) throw new Error("dynamic filter history carried without an initial snapshot");
|
|
236
|
+
let replayed = filters;
|
|
237
|
+
for (const bytes of history.deltas) replayed = replayed.applyDelta(decodeFilterDelta(bytes));
|
|
238
|
+
return replayed.withPredicateOrder(history.order);
|
|
239
|
+
}
|
|
@@ -35,6 +35,14 @@ export { PushdownFilters } from "./evaluate.js";
|
|
|
35
35
|
|
|
36
36
|
export { FilterV2Error, applyFilterDelta, buildJoinKeysLookup, deserializeFilters } from "./deserialize.js";
|
|
37
37
|
|
|
38
|
+
export {
|
|
39
|
+
EMPTY_FILTER_HISTORY,
|
|
40
|
+
type FilterDeltaHistory,
|
|
41
|
+
decodeDynamicFilterMetadata,
|
|
42
|
+
recordFilterDelta,
|
|
43
|
+
replayFilterHistory,
|
|
44
|
+
} from "./history.js";
|
|
45
|
+
|
|
38
46
|
export {
|
|
39
47
|
FilteringOutputCollector,
|
|
40
48
|
formatPushedFilters,
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
// Set `VGI_WORKER_SHARED_STORAGE=cloudflare-do` plus `VGI_CF_DO_URL`.
|
|
12
12
|
// Optionally set `VGI_CF_DO_TOKEN` for bearer auth.
|
|
13
13
|
|
|
14
|
-
import type { FunctionStorage } from "./storage.js";
|
|
15
|
-
import { UnknownInvocationError } from "./storage.js";
|
|
14
|
+
import type { FunctionStorage, WorkerStateKey } from "./storage.js";
|
|
15
|
+
import { UnknownInvocationError, workerStateKey } from "./storage.js";
|
|
16
16
|
|
|
17
17
|
/** Minimal fetch signature satisfied by both the global `fetch` and a
|
|
18
18
|
* Cloudflare service-binding Fetcher (`env.<BINDING>`). */
|
|
@@ -77,10 +77,10 @@ export class FunctionStorageCfDo implements FunctionStorage {
|
|
|
77
77
|
});
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
// --- Worker state → ns=worker, key = int64(worker_id) ---
|
|
80
|
+
// --- Worker state → ns=worker, key = substream_id bytes, or int64(worker_id) ---
|
|
81
81
|
|
|
82
|
-
async workerPut(executionId: Uint8Array,
|
|
83
|
-
await this._statePutMany(executionId, NS_WORKER, [[
|
|
82
|
+
async workerPut(executionId: Uint8Array, worker: WorkerStateKey, state: Uint8Array): Promise<void> {
|
|
83
|
+
await this._statePutMany(executionId, NS_WORKER, [[workerStateKey(worker), state]]);
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
async workerCollect(executionId: Uint8Array): Promise<Uint8Array[]> {
|
|
@@ -277,13 +277,8 @@ function newAttemptId(): string {
|
|
|
277
277
|
return s;
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
-
/**
|
|
281
|
-
|
|
282
|
-
const b = new Uint8Array(8);
|
|
283
|
-
new DataView(b.buffer).setBigInt64(0, BigInt(v), false);
|
|
284
|
-
return b;
|
|
285
|
-
}
|
|
286
|
-
|
|
280
|
+
/** Decode an 8-byte big-endian state key to its int64 worker id; any other
|
|
281
|
+
* key (a substream id) has none, and decodes to 0. */
|
|
287
282
|
function int64FromKey(b: Uint8Array): number {
|
|
288
283
|
if (b.length !== 8) return 0;
|
|
289
284
|
return Number(new DataView(b.buffer, b.byteOffset, 8).getBigInt64(0, false));
|