Skip to content

Commit e0fcffa

Browse files
rustyconoverclaude
andcommitted
fix(wire): spin-retry on stdio EAGAIN instead of 1ms sleep; flechette incremental encoder
Two fixes to the stdio (subprocess) transport: 1. writer: the non-blocking-pipe EAGAIN branch slept 1ms (Atomics.wait) per backpressure event. The engine drains the pipe in microseconds in lockstep, so this slept ~1000x too long and *dominated* large-batch write time — a 4KB-row passthru spent ~13s of a 15s run asleep here. Spin-retry instead (safe: the stdio path has a single peer, nothing to starve), with a bounded fallback sleep so a hung reader can't pin a core. ~9-12x on wire-bound TS workers; per-byte wire cost 20 -> 1.6 ns/byte. 2. flechette: implement createIncrementalEncoder (previously threw), unblocking the flechette Arrow backend on the stdio transport. It carves per-message frames (schema / dictionary / record batch) out of flechette's one-shot stream encoder via a FlatBuffer message walk (splitIpcMessages), deferring the schema to the first batch so dictionary ids stay consistent. Round-trips verified for single-batch, multi-batch, and dictionary-encoded columns. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b3545f5 commit e0fcffa

4 files changed

Lines changed: 119 additions & 23 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@query-farm/vgi-rpc",
3-
"version": "0.14.0",
3+
"version": "0.14.1",
44
"license": "Apache-2.0",
55
"homepage": "https://vgi-rpc-typescript.query.farm",
66
"repository": {

src/arrow/impl-flechette/index.ts

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ import type {
4646
VgiField,
4747
VgiSchema,
4848
} from "../types.js";
49-
import { readFirstRecordBatchMeta } from "./message-meta.js";
49+
import { readFirstRecordBatchMeta, splitIpcMessages } from "./message-meta.js";
5050
import { toFlechetteType } from "./normalize-type.js";
5151

5252
export const backend: VgiBackendInfo = { name: "flechette", opaquePassthrough: false };
@@ -397,18 +397,61 @@ export function conformBatchToSchema(batch: VgiBatch, schema: VgiSchema): VgiBat
397397
return rebuilt as unknown as VgiBatch;
398398
}
399399

400+
// End-of-stream marker: continuation (0xFFFFFFFF) + zero metadata length.
401+
const IPC_EOS = new Uint8Array(new Int32Array([-1, 0]).buffer);
402+
400403
/**
401-
* Not supported on flechette: the lockstep stdio exchange protocol needs an
402-
* incremental IPC writer (emit each batch's framing bytes before reading
403-
* the next input), and flechette only exposes a one-shot table encoder.
404-
* The flechette backend is selected for workerd / browser / worker, which
405-
* are HTTP-only (no stdio), so this factory is never reached there. The
406-
* stdio server requires the arrow-js backend.
404+
* Incremental IPC encoder for the flechette backend.
405+
*
406+
* flechette only exposes a one-shot stream encoder (`tableToIPC(x, { format:
407+
* "stream" })` emits a complete `[schema][dict…][recordbatch][EOS]` stream), but
408+
* the lockstep stdio protocol needs the messages emitted piecemeal: the schema
409+
* preamble once, then each batch's `[dict…][recordbatch]` body, then a single
410+
* EOS. We reconstruct that by serializing each part as a full stream and slicing
411+
* out the message frames we want (via `splitIpcMessages`, which parses the Arrow
412+
* Message envelopes rather than assuming byte lengths):
413+
*
414+
* start() → nothing; the schema is carried by the first batch (below).
415+
* writeBatch() → the first call emits `[schema][dict…][recordbatch]`; later
416+
* calls emit `[dict…][recordbatch]` (schema dropped).
417+
* finish() → the shared EOS marker (or a schema-only stream when no batch
418+
* was written, so an empty result still yields a valid stream).
419+
*
420+
* Why the schema is deferred to the first batch rather than serialized up front:
421+
* flechette assigns dictionary ids per serialization, and `serializeSchema`'s
422+
* empty-column path declares dictionary fields differently from a populated
423+
* batch — which desyncs the reader for dictionary-encoded columns. Two populated
424+
* batches of the same schema *do* share a byte-identical schema message, so
425+
* taking the schema from the first real batch keeps it consistent with every
426+
* batch's dictionary ids. Emitting each batch's dictionaries in full is a valid
427+
* Arrow *stream* dictionary replacement (isDelta=false), which the reader
428+
* accepts, so dictionary columns round-trip correctly.
407429
*/
408-
export function createIncrementalEncoder(_s: VgiSchema): IncrementalEncoder {
409-
throw new Error(
410-
"Incremental IPC streaming (stdio producer/exchange) is not supported on the flechette " +
411-
"Arrow backend. Use the arrow-js backend for the stdio transport, or the HTTP transport " +
412-
"on workerd/browser builds.",
413-
);
430+
export function createIncrementalEncoder(s: VgiSchema): IncrementalEncoder {
431+
let schemaEmitted = false;
432+
return {
433+
start: () => new Uint8Array(0),
434+
writeBatch(batch: VgiBatch): Uint8Array {
435+
const full = serializeBatch(batch); // [schema][dict…][recordbatch][EOS]
436+
const spans = splitIpcMessages(full); // excludes the trailing EOS
437+
const bodyEnd = spans.length ? spans[spans.length - 1].frameEnd : full.length;
438+
if (!schemaEmitted) {
439+
schemaEmitted = true;
440+
// First batch carries the schema: [schema][dict…][recordbatch].
441+
return full.subarray(0, bodyEnd);
442+
}
443+
// Later batches: drop the leading Schema message, keep [dict…][recordbatch].
444+
let start = -1;
445+
for (const m of spans) {
446+
if (m.headerType !== 1) {
447+
start = m.frameStart;
448+
break;
449+
}
450+
}
451+
return start < 0 ? new Uint8Array(0) : full.subarray(start, bodyEnd);
452+
},
453+
// A result with no batches still needs a schema on the wire; serializeSchema
454+
// produces a complete schema-only stream ([schema][EOS]).
455+
finish: () => (schemaEmitted ? IPC_EOS : serializeSchema(s)),
456+
};
414457
}

src/arrow/impl-flechette/message-meta.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,48 @@ export function readFirstRecordBatchMeta(
172172
}
173173
return null;
174174
}
175+
176+
/** Byte span of one on-wire IPC message (framing + metadata + body). */
177+
export interface IpcMessageSpan {
178+
/** Header type — 1 Schema, 2 DictionaryBatch, 3 RecordBatch. */
179+
headerType: number;
180+
/** Offset of the message's continuation marker in the stream. */
181+
frameStart: number;
182+
/** Offset just past the message's (padded) body. */
183+
frameEnd: number;
184+
}
185+
186+
/**
187+
* Split an Arrow IPC *stream* into its constituent message frames, stopping at
188+
* the end-of-stream marker. Each span covers the whole on-wire message
189+
* (continuation marker + metadata length + padded metadata + padded body).
190+
*
191+
* Used by the flechette incremental encoder to carve a one-shot
192+
* `tableToIPC(..., { format: "stream" })` output — a complete
193+
* `[schema][dict…][recordbatch][EOS]` stream — into the schema preamble and the
194+
* per-batch `[dict…][recordbatch]` body that the lockstep stdio protocol emits
195+
* separately.
196+
*/
197+
export function splitIpcMessages(stream: Uint8Array): IpcMessageSpan[] {
198+
const spans: IpcMessageSpan[] = [];
199+
let pos = 0;
200+
while (pos + 4 <= stream.length) {
201+
const frameStart = pos;
202+
let metaLen = readI32LE(stream, pos);
203+
pos += 4;
204+
if (metaLen === -1) {
205+
// Continuation-marker form (post-Arrow 0.15): the real length follows.
206+
if (pos + 4 > stream.length) break;
207+
metaLen = readI32LE(stream, pos);
208+
pos += 4;
209+
}
210+
if (metaLen === 0) break; // EOS
211+
if (pos + metaLen > stream.length) break;
212+
const head = stream.subarray(pos, pos + metaLen);
213+
pos += metaLen;
214+
const parsed = readMessageEnvelope(head);
215+
pos += parsed.bodyLength; // bodyLength is the padded on-wire body length
216+
spans.push({ headerType: parsed.headerType, frameStart, frameEnd: pos });
217+
}
218+
return spans;
219+
}

src/wire/writer.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,17 +46,27 @@ function _loadWriteSync(): (fd: number, data: Uint8Array, offset?: number, len?:
4646
function writeAll(fd: number, data: Uint8Array): void {
4747
const writeSync = _loadWriteSync();
4848
let offset = 0;
49+
let spins = 0;
4950
while (offset < data.length) {
5051
try {
5152
const written = writeSync(fd, data, offset, data.length - offset);
5253
if (written <= 0) throw new Error(`writeSync returned ${written}`);
5354
offset += written;
55+
spins = 0;
5456
} catch (e: any) {
5557
if (e.code === "EAGAIN") {
56-
// A non-blocking pipe — unexpected for stdio, but handle defensively.
57-
// Yielding is not possible from a synchronous function; busy-wait
58-
// briefly. AF_UNIX sockets must NOT use this path.
58+
// Non-blocking pipe backpressure. The stdio path has a single peer (the
59+
// engine), which drains the pipe within microseconds in lockstep, so
60+
// spin-retry rather than sleep: a 1ms `Atomics.wait` here is ~1000x too
61+
// coarse and *dominated* large-batch write time — a 4KB-row passthru
62+
// spent ~13s of a 15s run asleep in this branch, i.e. the whole TS
63+
// "wire tax" was this sleep. Spinning is safe here because there are no
64+
// other connections to starve (unlike the AF_UNIX socketWriteAll path,
65+
// which correctly awaits 'drain'). Fall back to a brief sleep only if
66+
// the peer looks genuinely stalled, so a hung reader can't pin a core.
67+
if (++spins < 8192) continue;
5968
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1);
69+
spins = 0;
6070
continue;
6171
}
6272
throw e;
@@ -192,12 +202,10 @@ export class IpcStreamWriter {
192202
*
193203
* The encoder is obtained from the Arrow facade, so this file no longer
194204
* imports arrow-js directly — keeping arrow-js out of the flechette
195-
* (workerd/browser) bundle. The flechette encoder throws on construction;
196-
* the stdio exchange protocol is lockstep (the client reads each response
197-
* batch before sending the next input) which needs an incremental writer
198-
* flechette doesn't provide. workerd/browser deployments use HTTP (no
199-
* stdio), so the flechette path is never reached there; `flechette-pipe`
200-
* conformance is xfailed for streams.
205+
* (workerd/browser) bundle. Both backends now provide an incremental encoder
206+
* (the flechette one carves per-message frames out of its one-shot stream
207+
* encoder), so the stdio lockstep exchange works on flechette as well as
208+
* arrow-js.
201209
*/
202210
export class IncrementalStream {
203211
private readonly encoder: IncrementalEncoder;

0 commit comments

Comments
 (0)