@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
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Copyright 2025, 2026 Query Farm LLC - https://query.farm
|
|
2
|
+
// The init request an HTTP stream carries in its cursor: packed once, parsed
|
|
3
|
+
// once per process.
|
|
4
|
+
//
|
|
5
|
+
// An HTTP stream has no memory between turns, so its cursor carries the whole
|
|
6
|
+
// init request (~9 KB of Arrow IPC for a DuckDB scan: the bind call with its
|
|
7
|
+
// arguments, settings and sealed attach, the output schema, the filter
|
|
8
|
+
// snapshot) and every turn used to re-parse it: the IPC read, the scalar-dict
|
|
9
|
+
// walk and the nested bind-call / schema / filter reads cost ~0.9 ms, the
|
|
10
|
+
// largest VGI-side share of the ~4 ms a 1000-row turn took. The cursor is also
|
|
11
|
+
// re-sealed and base64-encoded on every turn, so its size is paid twice per
|
|
12
|
+
// turn in AEAD and base64 work, and twice again on the wire.
|
|
13
|
+
//
|
|
14
|
+
// - `packInitRequest` zstd-compresses the request once, at /init (~9 KB ->
|
|
15
|
+
// ~2.6 KB), behind a one-byte codec tag. A runtime without synchronous zstd
|
|
16
|
+
// stores it raw.
|
|
17
|
+
// - `parseCarriedInitRequest` keeps a small per-process LRU from the packed
|
|
18
|
+
// bytes to their parse. It is a pure memo of a pure function: the key is the
|
|
19
|
+
// exact bytes (hash, then a full comparison), so a hit returns precisely
|
|
20
|
+
// what parsing those bytes would -- and those bytes came out of a cursor the
|
|
21
|
+
// worker sealed. Nothing principal-dependent is cached: the split tokens and
|
|
22
|
+
// the sealed attach are still opened, under the caller, on every turn. A
|
|
23
|
+
// miss (cold process, eviction, another replica's stream) parses as before,
|
|
24
|
+
// so correctness never depends on a hit.
|
|
25
|
+
|
|
26
|
+
import { deserializeBatch } from "../arrow/index.js";
|
|
27
|
+
import { batchToScalarDict } from "../util/arrow/index.js";
|
|
28
|
+
import { zstdSync } from "../util/zstd-sync.js";
|
|
29
|
+
import { deserializeInitRequest } from "./serialize.js";
|
|
30
|
+
import type { InitRequest } from "./types.js";
|
|
31
|
+
|
|
32
|
+
const RAW = 0;
|
|
33
|
+
const ZSTD = 1;
|
|
34
|
+
|
|
35
|
+
/** Pack an init request's IPC bytes for the cursor: `u8 codec | payload`. */
|
|
36
|
+
export function packInitRequest(ipc: Uint8Array): Uint8Array {
|
|
37
|
+
const codec = zstdSync();
|
|
38
|
+
const compressed = codec ? codec.compress(ipc) : null;
|
|
39
|
+
const useZstd = compressed !== null && compressed.byteLength < ipc.byteLength;
|
|
40
|
+
const payload = useZstd ? compressed! : ipc;
|
|
41
|
+
const packed = new Uint8Array(payload.byteLength + 1);
|
|
42
|
+
packed[0] = useZstd ? ZSTD : RAW;
|
|
43
|
+
packed.set(payload, 1);
|
|
44
|
+
return packed;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function unpackInitRequest(packed: Uint8Array): Uint8Array {
|
|
48
|
+
if (packed.byteLength === 0) throw new Error("state token carries no init request");
|
|
49
|
+
switch (packed[0]) {
|
|
50
|
+
case RAW:
|
|
51
|
+
return packed.slice(1);
|
|
52
|
+
case ZSTD: {
|
|
53
|
+
const codec = zstdSync();
|
|
54
|
+
if (!codec) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
"state token carries a zstd-compressed init request, and this runtime has no synchronous zstd " +
|
|
57
|
+
"(it was minted by a worker running under Bun or Node >= 22.15 with the same signing key)",
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return codec.decompress(packed.subarray(1));
|
|
61
|
+
}
|
|
62
|
+
default:
|
|
63
|
+
throw new Error(`state token carries an init request with unknown codec ${packed[0]}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** What a continuation needs from the carried init request. */
|
|
68
|
+
export interface CarriedInitRequest {
|
|
69
|
+
/**
|
|
70
|
+
* The parsed request -- a fresh top-level object with a fresh `bind_call`
|
|
71
|
+
* each call, so the per-turn assignments the dispatcher makes (split
|
|
72
|
+
* payloads) never reach the shared parse. The nested values (Arrow
|
|
73
|
+
* schemas and batches, arguments) are shared and must be treated as
|
|
74
|
+
* read-only, as they always were.
|
|
75
|
+
*/
|
|
76
|
+
request: InitRequest;
|
|
77
|
+
/** The raw bind-call IPC bytes, which the split-token fingerprint hashes. */
|
|
78
|
+
bindCallIpc: Uint8Array;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface Parsed {
|
|
82
|
+
packed: Uint8Array;
|
|
83
|
+
request: InitRequest;
|
|
84
|
+
bindCallIpc: Uint8Array;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Streams whose parse a process keeps. Past this, the least recently used is dropped. */
|
|
88
|
+
const MAX_ENTRIES = 128;
|
|
89
|
+
const parsed = new Map<number, Parsed>();
|
|
90
|
+
const stats = { parses: 0, hits: 0 };
|
|
91
|
+
|
|
92
|
+
/** FNV-1a over the bytes, folded with the length. */
|
|
93
|
+
function hashBytes(bytes: Uint8Array): number {
|
|
94
|
+
let h = 0x811c9dc5 ^ bytes.byteLength;
|
|
95
|
+
for (let i = 0; i < bytes.byteLength; i++) {
|
|
96
|
+
h ^= bytes[i];
|
|
97
|
+
h = Math.imul(h, 0x01000193);
|
|
98
|
+
}
|
|
99
|
+
return h >>> 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
|
|
103
|
+
if (a.byteLength !== b.byteLength) return false;
|
|
104
|
+
for (let i = 0; i < a.byteLength; i++) if (a[i] !== b[i]) return false;
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Parse the init request a cursor carries (as packed by
|
|
110
|
+
* {@link packInitRequest}), from the process memo when this stream's turns have
|
|
111
|
+
* been here before.
|
|
112
|
+
*/
|
|
113
|
+
export function parseCarriedInitRequest(packed: Uint8Array): CarriedInitRequest {
|
|
114
|
+
const key = hashBytes(packed);
|
|
115
|
+
let entry = parsed.get(key);
|
|
116
|
+
if (entry && sameBytes(entry.packed, packed)) {
|
|
117
|
+
// Refresh its recency.
|
|
118
|
+
parsed.delete(key);
|
|
119
|
+
parsed.set(key, entry);
|
|
120
|
+
stats.hits++;
|
|
121
|
+
} else {
|
|
122
|
+
const dict = batchToScalarDict(deserializeBatch(unpackInitRequest(packed)));
|
|
123
|
+
stats.parses++;
|
|
124
|
+
entry = { packed: packed.slice(), request: deserializeInitRequest(dict), bindCallIpc: dict.bind_call };
|
|
125
|
+
// A hash collision simply replaces the other stream's entry.
|
|
126
|
+
parsed.delete(key);
|
|
127
|
+
if (parsed.size >= MAX_ENTRIES) parsed.delete(parsed.keys().next().value!);
|
|
128
|
+
parsed.set(key, entry);
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
request: { ...entry.request, bind_call: { ...entry.request.bind_call } },
|
|
132
|
+
bindCallIpc: entry.bindCallIpc,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Cumulative parse / memo-hit counts for this process (observability and tests). */
|
|
137
|
+
export function carriedInitRequestStats(): { parses: number; hits: number; entries: number } {
|
|
138
|
+
return { ...stats, entries: parsed.size };
|
|
139
|
+
}
|
package/src/protocol/dispatch.ts
CHANGED
|
@@ -20,13 +20,6 @@ export interface ProtocolConfig {
|
|
|
20
20
|
registry: FunctionRegistry;
|
|
21
21
|
catalogInterface?: CatalogInterface;
|
|
22
22
|
catalogName?: string;
|
|
23
|
-
/**
|
|
24
|
-
* Recover accumulated exchange state from FINALIZE init_opaque_data.
|
|
25
|
-
* For HTTP transport, this unpacks the state token that the C++ extension
|
|
26
|
-
* passes from the last INPUT exchange to the FINALIZE init request.
|
|
27
|
-
* Returns the deserialized VGI dispatch state object (with userState field).
|
|
28
|
-
*/
|
|
29
|
-
recoverExchangeState?: (opaqueData: Uint8Array) => any;
|
|
30
23
|
/**
|
|
31
24
|
* AEAD signing key for sealing catalog opaque-data envelopes. Pass the same
|
|
32
25
|
* 32-byte key used for HTTP state tokens. When omitted (subprocess / unix
|
|
@@ -36,12 +29,38 @@ export interface ProtocolConfig {
|
|
|
36
29
|
signingKey?: Uint8Array;
|
|
37
30
|
}
|
|
38
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Wire name of the VGI protocol: the `vgi_rpc.protocol` routing key and the
|
|
34
|
+
* `{protocol}` HTTP path segment.
|
|
35
|
+
*
|
|
36
|
+
* The name carries the major version, so an incompatible major is a
|
|
37
|
+
* *different* protocol and therefore a 404 — an answer every proxy, WAF and
|
|
38
|
+
* load balancer understands without an Arrow parser, and one that lets
|
|
39
|
+
* `vgi.v2` and a future `vgi.v3` be served side by side while clients migrate.
|
|
40
|
+
* That matters for this consumer specifically: the DuckDB extension ships to
|
|
41
|
+
* users and cannot be flag-dayed.
|
|
42
|
+
*
|
|
43
|
+
* Declared rather than derived. Until the transports made the routing key
|
|
44
|
+
* required, each implementation's wire name defaulted to whatever its local
|
|
45
|
+
* type was called, which left the six ports disagreeing four ways — Python
|
|
46
|
+
* `VgiProtocol`, Java and C# `VgiService`, Go the framework default `Service`,
|
|
47
|
+
* this port `vgi` — so no client could address them all. The canonical name is
|
|
48
|
+
* decided in vgi-python and emitted by the DuckDB extension; this is that same
|
|
49
|
+
* string, not an independent choice.
|
|
50
|
+
*
|
|
51
|
+
* Since `@query-farm/vgi-rpc` 0.24.0 the name is also bound into the AEAD
|
|
52
|
+
* associated data of every state and call token (`TokenScope.protocol`), so a
|
|
53
|
+
* caller that opens a token minted by this protocol has to name it. Exported
|
|
54
|
+
* so the HTTP entry points build that scope from the same constant the
|
|
55
|
+
* protocol is registered under rather than a second copy of the literal.
|
|
56
|
+
*/
|
|
57
|
+
export const VGI_PROTOCOL_NAME = "vgi.v2";
|
|
58
|
+
|
|
39
59
|
export function buildVgiProtocol(config: ProtocolConfig): Protocol {
|
|
40
|
-
const protocol = new Protocol(
|
|
60
|
+
const protocol = new Protocol(VGI_PROTOCOL_NAME, { protocolVersion: "2.0.0" });
|
|
41
61
|
|
|
42
62
|
registerFunctionMethods(protocol, {
|
|
43
63
|
registry: config.registry,
|
|
44
|
-
recoverExchangeState: config.recoverExchangeState,
|
|
45
64
|
signingKey: config.signingKey,
|
|
46
65
|
catalogInterface: config.catalogInterface,
|
|
47
66
|
});
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
import { type VgiSchema, schema, type VgiField, field, type VgiDataType, binary, utf8, bool } from "../../../arrow/index.js";
|
|
6
6
|
import { Protocol, type CallContext } from "@query-farm/vgi-rpc";
|
|
7
|
-
import { encodeSchemaInfo, encodeTableInfo, encodeViewInfo,
|
|
7
|
+
import { encodeSchemaInfo, encodeTableInfo, encodeViewInfo, encodeCatalogInfo } from "../../../generated/vgi-client.js";
|
|
8
|
+
import { encodeFunctionInfoOnce } from "../../../catalog/item-encoding.js";
|
|
8
9
|
import { encodeCopyFromFormatInfo } from "../../../catalog/interface.js";
|
|
9
10
|
import {
|
|
10
11
|
CatalogAttachParamsSchema,
|
|
@@ -360,8 +361,10 @@ export function registerCatalogAdminMethods(protocol: Protocol, getCatalog: GetC
|
|
|
360
361
|
decodeDictValue(params.type),
|
|
361
362
|
params.transaction_opaque_data ? toUint8Array(params.transaction_opaque_data) : undefined
|
|
362
363
|
);
|
|
364
|
+
// A frozen item (a descriptor catalog's cached listing) is encoded once;
|
|
365
|
+
// anything else is encoded per call, as before (see item-encoding.ts).
|
|
363
366
|
return wrapResult({
|
|
364
|
-
items: funcs.map((f) =>
|
|
367
|
+
items: funcs.map((f) => encodeFunctionInfoOnce(f)),
|
|
365
368
|
}, CatalogSchemaContentsFunctionsResultSchema);
|
|
366
369
|
},
|
|
367
370
|
});
|
|
@@ -37,9 +37,9 @@ import {
|
|
|
37
37
|
unwrapRequest,
|
|
38
38
|
wrapResult,
|
|
39
39
|
overloadContext,
|
|
40
|
-
recoverFinalizeState,
|
|
41
40
|
} from "./shared.js";
|
|
42
41
|
import { GLOBAL_INIT_RESPONSE_SCHEMA } from "../serializers/init.js";
|
|
42
|
+
import { parseCarriedInitRequest } from "../carried-init-request.js";
|
|
43
43
|
import { openAttach } from "./catalog/shared.js";
|
|
44
44
|
import { currentRequestAuth } from "../../request-auth.js";
|
|
45
45
|
import { batchFromColumns, serializeBatch } from "../../util/arrow/index.js";
|
|
@@ -61,7 +61,6 @@ import {
|
|
|
61
61
|
|
|
62
62
|
export interface FunctionHandlerConfig {
|
|
63
63
|
registry: FunctionRegistry;
|
|
64
|
-
recoverExchangeState?: (opaqueData: Uint8Array) => any;
|
|
65
64
|
signingKey?: Uint8Array;
|
|
66
65
|
/**
|
|
67
66
|
* Used to map a bind's `attach_opaque_data` to its catalog, so resolution can
|
|
@@ -314,11 +313,11 @@ export function registerFunctionMethods(protocol: Protocol, config: FunctionHand
|
|
|
314
313
|
// FunctionStorage (e.g. Cloudflare DO).
|
|
315
314
|
const initResponse = await func.globalInit(request);
|
|
316
315
|
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
const handlers = func.createStreamHandlers(request, initResponse
|
|
316
|
+
// A FINALIZE stream reads its accumulated states from storage, where
|
|
317
|
+
// every INPUT turn persisted them. Its `init_opaque_data` is the primary
|
|
318
|
+
// init's opaque data echoed back (vgi-python's InitRequest), never a
|
|
319
|
+
// cursor, so nothing here opens it.
|
|
320
|
+
const handlers = func.createStreamHandlers(request, initResponse);
|
|
322
321
|
|
|
323
322
|
// Initialize the appropriate handler
|
|
324
323
|
let handlerState: HandlerState | undefined;
|
|
@@ -384,9 +383,10 @@ export function registerFunctionMethods(protocol: Protocol, config: FunctionHand
|
|
|
384
383
|
// Deserialized from token — reconstruct from serializable refs.
|
|
385
384
|
// Infrastructure (processParams, BoundStorage) is recreated fresh.
|
|
386
385
|
// Mutable user state is merged from state.userState.
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
const
|
|
386
|
+
// Parsed once per process per stream (carried-init-request.ts): the
|
|
387
|
+
// cursor carries the packed request unchanged on every turn.
|
|
388
|
+
const carried = parseCarriedInitRequest(state.initRequestPacked);
|
|
389
|
+
const request = carried.request;
|
|
390
390
|
// Re-derive the split payloads. The cursor carries the wire request,
|
|
391
391
|
// which has `split_tokens` but not the opened `split_payloads` — that
|
|
392
392
|
// field only ever existed in memory on the init turn. Without this a
|
|
@@ -399,7 +399,7 @@ export function registerFunctionMethods(protocol: Protocol, config: FunctionHand
|
|
|
399
399
|
// pre-deserialization blob from the cursor — handing it the decoded
|
|
400
400
|
// object hashes a different shape and every token fails as
|
|
401
401
|
// SPLIT_TOKEN_INVALID "minted for a different bind".
|
|
402
|
-
await resolveSplitPayloads(request, { bind_call:
|
|
402
|
+
await resolveSplitPayloads(request, { bind_call: carried.bindCallIpc }, {
|
|
403
403
|
// Same principal the tokens were sealed under. The exchange dispatch
|
|
404
404
|
// gets no ctx (see stripAttach above), so the request-scoped identity
|
|
405
405
|
// is the only source — opening under the anonymous tail would fail
|
|
@@ -418,10 +418,10 @@ export function registerFunctionMethods(protocol: Protocol, config: FunctionHand
|
|
|
418
418
|
opaque_data: opaqueData,
|
|
419
419
|
};
|
|
420
420
|
|
|
421
|
-
//
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
handlers = func.createStreamHandlers(request, initResponse,
|
|
421
|
+
// The cursor's compacted dynamic-filter history: without it every turn
|
|
422
|
+
// after the one that received a delta would scan under the init
|
|
423
|
+
// snapshot again, because the extension sends each delta only once.
|
|
424
|
+
handlers = func.createStreamHandlers(request, initResponse, state.filterHistory ?? null);
|
|
425
425
|
if (handlers.producerInit) {
|
|
426
426
|
handlerState = handlers.producerInit();
|
|
427
427
|
} else if (handlers.exchangeInit) {
|
|
@@ -453,6 +453,9 @@ export function registerFunctionMethods(protocol: Protocol, config: FunctionHand
|
|
|
453
453
|
if (handlerState?.state !== undefined) {
|
|
454
454
|
state.userState = handlerState!.state;
|
|
455
455
|
}
|
|
456
|
+
// And the dynamic-filter history this turn left (compacted: bounded by
|
|
457
|
+
// the number of predicate ids, not the number of turns).
|
|
458
|
+
state.filterHistory = handlerState?.filterHistory ?? null;
|
|
456
459
|
},
|
|
457
460
|
headerSchema: initHeaderSchema,
|
|
458
461
|
headerInit: (params: any, state: any, ctx: any) => {
|
|
@@ -5,8 +5,6 @@ import { type VgiSchema, schema, type VgiField, field, type VgiDataType, binary
|
|
|
5
5
|
import type { OverloadContext } from "../../functions/registry.js";
|
|
6
6
|
import { batchToScalarDict, deserializeBatch, serializeBatch, batchFromColumns } from "../../util/arrow/index.js";
|
|
7
7
|
import { toUint8Array } from "../../util/bytes.js";
|
|
8
|
-
import { TableInOutPhase } from "../../types.js";
|
|
9
|
-
import type { InitRequest } from "../types.js";
|
|
10
8
|
import { normalizeSchemaPath } from "../../schema-path.js";
|
|
11
9
|
|
|
12
10
|
// The Python vgi-rpc framework wraps ALL non-void unary results in a single
|
|
@@ -91,23 +89,3 @@ export function overloadContext(
|
|
|
91
89
|
catalogName,
|
|
92
90
|
};
|
|
93
91
|
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Recover accumulated exchange state from a FINALIZE init request.
|
|
97
|
-
* For HTTP transport, this unpacks the state token that the C++ extension
|
|
98
|
-
* passes from the last INPUT exchange to the FINALIZE init request.
|
|
99
|
-
*/
|
|
100
|
-
export function recoverFinalizeState(
|
|
101
|
-
request: InitRequest,
|
|
102
|
-
recoverExchangeState: ((opaqueData: Uint8Array) => any) | undefined,
|
|
103
|
-
): any {
|
|
104
|
-
if (request.phase === TableInOutPhase.FINALIZE && request.init_opaque_data && recoverExchangeState) {
|
|
105
|
-
try {
|
|
106
|
-
const recovered = recoverExchangeState(request.init_opaque_data);
|
|
107
|
-
return recovered?.userState;
|
|
108
|
-
} catch (e: any) {
|
|
109
|
-
throw new Error(`Failed to recover FINALIZE state from init_opaque_data: ${e.message}`);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
return undefined;
|
|
113
|
-
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// Copyright 2025, 2026 Query Farm LLC - https://query.farm
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// avoiding base64/hex overhead and staying consistent with the rest of the protocol.
|
|
2
|
+
// State serializer for the HTTP transport: the exchange state an HTTP cursor
|
|
3
|
+
// carries between turns, and the Arrow IPC encoding of user state inside it.
|
|
5
4
|
|
|
6
5
|
import {
|
|
7
6
|
type VgiDataType,
|
|
@@ -27,21 +26,42 @@ import {
|
|
|
27
26
|
import { codecFor } from "../arrow/codec/registry.js";
|
|
28
27
|
import type { StateSerializer } from "@query-farm/vgi-rpc";
|
|
29
28
|
import { toUint8Array } from "../util/bytes.js";
|
|
29
|
+
import { decodeFilterHistory, encodeFilterHistory } from "../filter-pushdown/history.js";
|
|
30
|
+
import { packInitRequest } from "./carried-init-request.js";
|
|
30
31
|
|
|
31
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* The fields of the exchange state an HTTP cursor carries, by name and type.
|
|
34
|
+
*
|
|
35
|
+
* Descriptive: {@link arrowStateSerializer} writes them as a flat binary frame
|
|
36
|
+
* (see there), not as an Arrow batch of this schema. Kept exported for API
|
|
37
|
+
* stability.
|
|
38
|
+
*/
|
|
32
39
|
export const EXCHANGE_STATE_SCHEMA = makeSchema([
|
|
33
40
|
field("function_name", binary(), false),
|
|
41
|
+
// The init request, packed by carried-init-request.ts (a codec byte, then
|
|
42
|
+
// the IPC bytes, zstd-compressed where the runtime can).
|
|
34
43
|
field("init_request", binary(), false),
|
|
35
44
|
field("execution_id", binary(), false),
|
|
36
45
|
field("max_workers", int64(), false),
|
|
37
46
|
field("opaque_data", binary(), true),
|
|
38
47
|
field("is_producer", bool(), false),
|
|
39
48
|
field("user_state", binary(), true),
|
|
49
|
+
// The stream's compacted dynamic-filter history (filter-pushdown/history.ts),
|
|
50
|
+
// null when no delta has been applied. Bounded by the number of predicate
|
|
51
|
+
// ids, so the token stays flat-sized however many turns the scan takes.
|
|
52
|
+
field("filter_history", binary(), true),
|
|
40
53
|
]);
|
|
41
54
|
|
|
42
55
|
const TEXT_ENCODER = new TextEncoder();
|
|
43
56
|
const TEXT_DECODER = new TextDecoder();
|
|
44
57
|
|
|
58
|
+
/** Version byte of the exchange state frame (never 0xFF, the first byte of an Arrow IPC stream). */
|
|
59
|
+
const STATE_FORMAT_VERSION = 1;
|
|
60
|
+
const FLAG_PRODUCER = 1;
|
|
61
|
+
const FLAG_OPAQUE_DATA = 2;
|
|
62
|
+
const FLAG_USER_STATE = 4;
|
|
63
|
+
const FLAG_FILTER_HISTORY = 8;
|
|
64
|
+
|
|
45
65
|
/**
|
|
46
66
|
* Child field name for an inferred list. Arrow matches list children by
|
|
47
67
|
* position, not name, so this is cosmetic — but keeping it at Arrow's
|
|
@@ -187,49 +207,101 @@ export function deserializeUserState(bytes: Uint8Array | null): any {
|
|
|
187
207
|
return result;
|
|
188
208
|
}
|
|
189
209
|
|
|
190
|
-
/**
|
|
210
|
+
/**
|
|
211
|
+
* The HTTP exchange state serializer: what a stream's cursor carries between
|
|
212
|
+
* turns.
|
|
213
|
+
*
|
|
214
|
+
* A flat, versioned binary frame, not an Arrow batch. The cursor is built and
|
|
215
|
+
* parsed on every turn of every HTTP stream, and wrapping eight scalars in a
|
|
216
|
+
* one-row Arrow batch cost ~0.4 ms a turn (a generic column builder per field,
|
|
217
|
+
* an IPC schema message each way) plus ~1 KB of framing that was then sealed
|
|
218
|
+
* and base64-encoded twice per turn. The fields are the ones
|
|
219
|
+
* {@link EXCHANGE_STATE_SCHEMA} names; `user_state` stays Arrow IPC inside the
|
|
220
|
+
* frame, because it carries typed user values.
|
|
221
|
+
*
|
|
222
|
+
* Layout (little-endian): `u8 version | u8 flags | f64 max_workers |
|
|
223
|
+
* bytes function_name | bytes init_request | bytes execution_id |
|
|
224
|
+
* [bytes opaque_data] [bytes user_state] [bytes filter_history]`, where
|
|
225
|
+
* `bytes` is `u32 length, payload` and a bracketed field is present only when
|
|
226
|
+
* its flag bit is set. The frame never leaves this worker unsealed, so the
|
|
227
|
+
* version byte exists to turn a token minted by another build into a clean
|
|
228
|
+
* refusal rather than a misread.
|
|
229
|
+
*
|
|
230
|
+
* The name is kept for API stability; the Arrow in it is the user state.
|
|
231
|
+
*/
|
|
191
232
|
export const arrowStateSerializer: StateSerializer = {
|
|
192
233
|
serialize(state: any): Uint8Array {
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
234
|
+
const functionName = TEXT_ENCODER.encode(state.functionName ?? "");
|
|
235
|
+
// Packed on the init turn (the only one holding the raw bytes); every
|
|
236
|
+
// continuation carries the packed bytes through unchanged.
|
|
237
|
+
const initRequest: Uint8Array = state.initRequestPacked ?? packInitRequest(state.initRequestIpc);
|
|
238
|
+
const executionId = toUint8Array(state.executionId);
|
|
239
|
+
const opaqueData = state.opaqueData != null ? toUint8Array(state.opaqueData) : null;
|
|
240
|
+
const userState = serializeUserState(state.userState);
|
|
241
|
+
const filterHistory = encodeFilterHistory(state.filterHistory);
|
|
242
|
+
const flags =
|
|
243
|
+
(state.isProducer ? FLAG_PRODUCER : 0) |
|
|
244
|
+
(opaqueData ? FLAG_OPAQUE_DATA : 0) |
|
|
245
|
+
(userState ? FLAG_USER_STATE : 0) |
|
|
246
|
+
(filterHistory ? FLAG_FILTER_HISTORY : 0);
|
|
247
|
+
const chunks = [functionName, initRequest, executionId];
|
|
248
|
+
if (opaqueData) chunks.push(opaqueData);
|
|
249
|
+
if (userState) chunks.push(userState);
|
|
250
|
+
if (filterHistory) chunks.push(filterHistory);
|
|
251
|
+
let size = 2 + 8;
|
|
252
|
+
for (const chunk of chunks) size += 4 + chunk.byteLength;
|
|
253
|
+
const out = new Uint8Array(size);
|
|
254
|
+
const view = new DataView(out.buffer);
|
|
255
|
+
out[0] = STATE_FORMAT_VERSION;
|
|
256
|
+
out[1] = flags;
|
|
257
|
+
view.setFloat64(2, Number(state.maxWorkers ?? 1), true);
|
|
258
|
+
let offset = 10;
|
|
259
|
+
for (const chunk of chunks) {
|
|
260
|
+
view.setUint32(offset, chunk.byteLength, true);
|
|
261
|
+
out.set(chunk, offset + 4);
|
|
262
|
+
offset += 4 + chunk.byteLength;
|
|
263
|
+
}
|
|
264
|
+
return out;
|
|
204
265
|
},
|
|
205
266
|
|
|
206
267
|
deserialize(bytes: Uint8Array): any {
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
268
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
269
|
+
if (bytes.byteLength < 10 || bytes[0] !== STATE_FORMAT_VERSION) {
|
|
270
|
+
throw new Error("exchange state has an unknown format (minted by another build of this worker?)");
|
|
271
|
+
}
|
|
272
|
+
const flags = bytes[1];
|
|
273
|
+
const maxWorkers = view.getFloat64(2, true);
|
|
274
|
+
let offset = 10;
|
|
275
|
+
// Each field is copied out, not viewed: Arrow readers want a buffer of
|
|
276
|
+
// their own (byteOffset 0), and nothing should pin the whole frame.
|
|
277
|
+
const take = (): Uint8Array => {
|
|
278
|
+
if (offset + 4 > bytes.byteLength) throw new Error("exchange state is truncated");
|
|
279
|
+
const length = view.getUint32(offset, true);
|
|
280
|
+
offset += 4;
|
|
281
|
+
if (offset + length > bytes.byteLength) throw new Error("exchange state is truncated");
|
|
282
|
+
const chunk = bytes.slice(offset, offset + length);
|
|
283
|
+
offset += length;
|
|
284
|
+
return chunk;
|
|
215
285
|
};
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
const
|
|
219
|
-
const
|
|
220
|
-
|
|
286
|
+
const functionName = TEXT_DECODER.decode(take());
|
|
287
|
+
const initRequestPacked = take();
|
|
288
|
+
const executionId = take();
|
|
289
|
+
const opaqueData = flags & FLAG_OPAQUE_DATA ? take() : null;
|
|
290
|
+
const userStateBytes = flags & FLAG_USER_STATE ? take() : null;
|
|
291
|
+
const filterHistoryBytes = flags & FLAG_FILTER_HISTORY ? take() : null;
|
|
292
|
+
if (offset !== bytes.byteLength) throw new Error("exchange state has trailing bytes");
|
|
293
|
+
const isProducer = (flags & FLAG_PRODUCER) !== 0;
|
|
221
294
|
return {
|
|
222
|
-
functionName
|
|
223
|
-
|
|
224
|
-
executionId
|
|
225
|
-
maxWorkers
|
|
226
|
-
opaqueData
|
|
295
|
+
functionName,
|
|
296
|
+
initRequestPacked,
|
|
297
|
+
executionId,
|
|
298
|
+
maxWorkers,
|
|
299
|
+
opaqueData,
|
|
227
300
|
isProducer,
|
|
228
301
|
// vgi-rpc dispatch reads __isProducer to choose producer vs exchange mode
|
|
229
302
|
__isProducer: isProducer,
|
|
230
|
-
userState: deserializeUserState(
|
|
231
|
-
|
|
232
|
-
),
|
|
303
|
+
userState: deserializeUserState(userStateBytes),
|
|
304
|
+
filterHistory: decodeFilterHistory(filterHistoryBytes),
|
|
233
305
|
};
|
|
234
306
|
},
|
|
235
307
|
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Copyright 2025, 2026 Query Farm LLC - https://query.farm
|
|
2
|
+
// Synchronous zstd, where the runtime has it.
|
|
3
|
+
//
|
|
4
|
+
// The state serializer is synchronous (vgi-rpc's StateSerializer contract), so
|
|
5
|
+
// it cannot use the async helpers vgi-rpc exposes. Bun has
|
|
6
|
+
// `Bun.zstdCompressSync`; Node >= 22.15 has `zlib.zstdCompressSync`, reached
|
|
7
|
+
// from ESM through `process.getBuiltinModule`. Anything else (workerd, a
|
|
8
|
+
// browser) gets `null` and callers fall back to storing bytes uncompressed.
|
|
9
|
+
|
|
10
|
+
/** A synchronous zstd codec. */
|
|
11
|
+
export interface ZstdSync {
|
|
12
|
+
compress(data: Uint8Array): Uint8Array;
|
|
13
|
+
decompress(data: Uint8Array): Uint8Array;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Level 3 is zstd's default: ~3.4x on an init request, ~50 us for 9 KB. */
|
|
17
|
+
const LEVEL = 3;
|
|
18
|
+
|
|
19
|
+
function resolveZstdSync(): ZstdSync | null {
|
|
20
|
+
const bun = (globalThis as any).Bun;
|
|
21
|
+
if (typeof bun?.zstdCompressSync === "function" && typeof bun?.zstdDecompressSync === "function") {
|
|
22
|
+
return {
|
|
23
|
+
compress: (data) => new Uint8Array(bun.zstdCompressSync(data, { level: LEVEL })),
|
|
24
|
+
// Copied into a fresh buffer: Arrow readers want byteOffset 0.
|
|
25
|
+
decompress: (data) => new Uint8Array(bun.zstdDecompressSync(data)),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
let zlib: any = null;
|
|
29
|
+
try {
|
|
30
|
+
zlib = (globalThis as any).process?.getBuiltinModule?.("node:zlib") ?? null;
|
|
31
|
+
} catch {
|
|
32
|
+
zlib = null;
|
|
33
|
+
}
|
|
34
|
+
if (typeof zlib?.zstdCompressSync === "function" && typeof zlib?.zstdDecompressSync === "function") {
|
|
35
|
+
const params = { [zlib.constants.ZSTD_c_compressionLevel]: LEVEL };
|
|
36
|
+
return {
|
|
37
|
+
compress: (data) => new Uint8Array(zlib.zstdCompressSync(data, { params })),
|
|
38
|
+
decompress: (data) => new Uint8Array(zlib.zstdDecompressSync(data)),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let resolved: ZstdSync | null | undefined;
|
|
45
|
+
|
|
46
|
+
/** This runtime's synchronous zstd codec, or `null` when it has none. */
|
|
47
|
+
export function zstdSync(): ZstdSync | null {
|
|
48
|
+
if (resolved === undefined) resolved = resolveZstdSync();
|
|
49
|
+
return resolved;
|
|
50
|
+
}
|