Setting the file. One moment.
Stream · Clickhouse Js Node Rowbinary · ClickHouse/agent-skills · Skills Docs
ContentsBack to the top of the page Bundled file
src/readers/ stream.ts
TypeScript · 276 lines · 12 KB
10
chunks
:
number
;
11 /** Rows decoded so far. */
12 rows : number ;
13 /** `rows / chunks` — the ratio that tripped the threshold. */
14 rowsPerChunk : number ;
15 }
16
17 /**
18 * Tuning for { @link streamRowBatches } 's small-chunk warning. Pass `false` to
19 * disable it, `true` / omit for the defaults, or an object to tune.
20 */
21 export type WarnOnSmallChunks =
22 | boolean
23 | {
24 /**
25 * Warn when the running `rows / chunks` average drops below this. Default
26 * `2`: throw + restart re-decodes the partial trailing row on EVERY chunk,
27 * so once a chunk barely covers a row or two the re-scan dominates — the
28 * regime where `streamingRow.bench.ts` shows throw+restart losing to a lean
29 * generator. Keep it low so the warning only fires when chunks are
30 * genuinely too small, never on a healthy hundreds-of-rows-per-chunk stream.
31 */
32 minRowsPerChunk ?: number ;
33 /**
34 * Don't evaluate until this many chunks have been seen. Default `16`:
35 * lets the average settle and suppresses the warning on small results,
36 * where the gotcha doesn't bite (it only matters at megabytes / millions
37 * of rows). A stream that ends before this never warns.
38 */
39 warmupChunks ?: number ;
40 /** Where the warning goes. Default `console.warn`. */
41 warn ?: ( message : string , stats : SmallChunkStats ) => void ;
42 };
43
44 /** Options for { @link streamRowBatches } . */
45 export interface StreamRowBatchesOptions {
46 /**
47 * Diagnostic that catches a silent throughput killer: chunks so small that the
48 * throw+restart streaming strategy spends most of its time re-decoding the
49 * partial trailing row instead of making progress. Fires AT MOST ONCE per
50 * stream. On by default; see { @link WarnOnSmallChunks } to tune or disable.
51 *
52 * The fix it points at is usually upstream — raise the HTTP response's read
53 * size (Node sets the socket/stream `highWaterMark`; a fetch `Response.body`
54 * reader delivers larger chunks than a hand-rolled tiny read) into the
55 * tens–hundreds of KB range — or, when chunk size isn't yours to control,
56 * compose { @link coalesceChunks } in front to merge small chunks first.
57 */
58 warnOnSmallChunks ?: WarnOnSmallChunks ;
59 }
60
61 /**
62 * Stream a chunked `RowBinary` response into batches of decoded rows. This is
63 * the async front door built on { @link readRows } : feed it the byte chunks of an
64 * HTTP response (anything async-iterable — a Node `Readable`, `response.body`,
65 * etc.) and a per-row `Reader`, and `for await` the batches.
66 *
67 * One batch is yielded per incoming chunk — exactly the rows that completed
68 * within it — so batch size tracks chunk size, which the caller controls. A
69 * chunk that doesn't complete a new row yields nothing; its bytes are carried
70 * into the next chunk. Empty batches are never yielded.
71 *
72 * How it works (the carry-buffer driver):
73 * - Join the leftover `carry` from the previous chunk to the new chunk, build a
74 * state over the join, and run `readRows`. It decodes whole rows, stops cleanly
75 * on the partial trailing row (catching `NeedMoreData`), and leaves `pos` at
76 * that row's start.
77 * - The unread tail `pos..end` becomes the next `carry` as a `subarray` VIEW,
78 * NOT a copy. The joined buffer is owned entirely by this generator — it is
79 * never yielded to the caller — so there is no aliasing hazard in keeping a
80 * view into it, and we skip a per-chunk copy of the tail. The view is also
81 * short-lived: the next chunk's `Buffer.concat` copies these bytes into a
82 * fresh buffer, after which the old one is released.
83 * - When the stream ends, any non-empty carry means the response was truncated
84 * mid-row — a malformed stream — so it throws rather than silently dropping
85 * bytes.
86 *
87 * `readRow` is a `Reader<T>` — write it as `(s) => ({ id: readUInt64(s),
88 * name: readString(s) })`. Build any configured/combinator readers ONCE (e.g.
89 * `const readRow = readTupleNamed({...})`) and reuse, rather than rebuilding them
90 * per chunk.
91 *
92 * ZERO-COPY NOTE: raw-bytes readers (`readUUID`/`readIPv6`/`readFixedStringBytes`
93 * and binary `String`) return views into the current chunk's joined buffer. Those
94 * stay valid as long as you hold the row objects, but are NOT views into one
95 * stable buffer across batches. If you retain them long-term, copy in `readRow`.
96 *
97 * BACKPRESSURE: this is a pull stream — the next chunk is only requested when the
98 * consumer asks for the next batch, so a slow consumer naturally throttles reading.
99 *
100 * The per-chunk bookkeeping for the small-chunk warning (two integer adds and a
101 * compare) runs once per CHUNK, not per row, so it is off every hot path; the
102 * default-on warning is documented in { @link StreamRowBatchesOptions } .
103 */
104 export async function* streamRowBatches < T >(
105 chunks : AsyncIterable < Uint8Array >,
106 readRow : Reader < T >,
107 options ?: StreamRowBatchesOptions ,
108 ) : AsyncGenerator < T [], void , undefined > {
109 const drive = readRows (readRow);
110 let carry : Buffer < ArrayBufferLike > = EMPTY_CHUNK ;
111
112 // Resolve the warning config once, outside the loop.
113 const warnCfg = options?.warnOnSmallChunks;
114 const warnEnabled = warnCfg !== false ;
115 const warnObj = typeof warnCfg === "object" ? warnCfg : undefined ;
116 const minRowsPerChunk = warnObj?.minRowsPerChunk ?? 2 ;
117 const warmupChunks = warnObj?.warmupChunks ?? 16 ;
118 const warn = warnObj?.warn ?? (( message : string ) => console. warn (message));
119 let chunkCount = 0 ;
120 let rowCount = 0 ;
121 let warned = false ;
122
123 for await ( const chunk of chunks) {
124 // Normalize to a Buffer without copying (a Uint8Array shares its ArrayBuffer).
125 const incoming = Buffer. isBuffer (chunk)
126 ? chunk
127 : Buffer. from (chunk.buffer, chunk.byteOffset, chunk.byteLength);
128 const work =
129 carry. length === 0 ? incoming : Buffer. concat ([carry, incoming]);
130
131 const state = new Cursor (work);
132 const rows = drive (state);
133 if (rows. length > 0 ) yield rows;
134
135 // Carry the unread tail (the partial trailing row, if any) to the next
136 // chunk. A view, not a copy: we own `work` and never expose it, so keeping a
137 // subarray into it is safe; the next concat copies these bytes out.
138 carry = state.pos < work. length ? work. subarray (state.pos) : EMPTY_CHUNK ;
139
140 if (warnEnabled && ! warned) {
141 chunkCount ++ ;
142 rowCount += rows. length ;
143 const rowsPerChunk = rowCount / chunkCount;
144 if (chunkCount >= warmupChunks && rowsPerChunk < minRowsPerChunk) {
145 warned = true ;
146 warn (
147 `RowBinary stream: chunks look too small — ${ rowsPerChunk . toFixed ( 2 ) } rows/chunk over ${ chunkCount } chunks. ` +
148 `Streaming throws + restarts the partial trailing row on every chunk, so tiny chunks spend most of their ` +
149 `time re-decoding instead of advancing. Increase the upstream read/highWaterMark to tens–hundreds of KB, ` +
150 `or compose coalesceChunks() in front of this stream to merge small chunks first.` ,
151 { chunks: chunkCount, rows: rowCount, rowsPerChunk },
152 );
153 }
154 }
155 }
156 if (carry. length > 0 ) {
157 throw new Error (
158 `RowBinary stream ended mid-row: ${ carry . length } trailing byte(s) left undecoded` ,
159 );
160 }
161 }
162
163 /** A timeout result distinct from any `IteratorResult`. */
164 const TIMED_OUT = Symbol ( "coalesceChunks.timeout" );
165
166 /**
167 * Coalesce (debounce) a chunk stream so each emitted chunk is at least `minSize`
168 * bytes — a filter you compose IN FRONT of { @link streamRowBatches } when the
169 * source delivers chunks too small to stream efficiently and you can't enlarge
170 * them upstream:
171 *
172 * streamRowBatches(coalesceChunks(httpChunks, { minSize: 64 * 1024, timeoutMs: 50 }), readRow)
173 *
174 * WHY: the throw+restart streaming strategy re-decodes the partial trailing row
175 * on every chunk boundary, so the smaller the chunks the more time is wasted
176 * re-scanning (see `streamingRow.bench.ts`). Merging small chunks up front cuts
177 * the number of boundaries — and the backtracking with it.
178 *
179 * THE TRADE-OFF (latency vs. reallocation vs. backtracking): merging holds bytes
180 * back until enough accumulate, so it ADDS up to `timeoutMs` of latency to data
181 * that arrives in a trickle, and it COPIES via `Buffer.concat` to join the parts
182 * (one extra allocation per emitted chunk). In return the downstream parser
183 * backtracks far less. Tune `minSize` to the downstream sweet spot (tens–hundreds
184 * of KB) and `timeoutMs` to the latency you can spare.
185 *
186 * SEMANTICS:
187 * - Accumulates incoming chunks until their total reaches `minSize`, then emits
188 * the join immediately.
189 * - A batch below `minSize` is flushed early when `timeoutMs` elapses from the
190 * moment its FIRST byte arrived (the deadline is anchored, not reset per
191 * chunk — a steady trickle of tiny chunks can't defer the flush forever).
192 * - While nothing is buffered it blocks indefinitely for the next chunk: an idle
193 * or finished stream is never charged the timeout.
194 * - End of stream flushes whatever remains (possibly below `minSize`); a single
195 * already-large-enough chunk passes straight through with no copy.
196 *
197 * It keeps exactly ONE outstanding pull on the source at a time (never calls
198 * `next()` while a prior result is still in flight), reads one chunk ahead so it
199 * can race arrival against the timer, and releases the source via `return()` if
200 * the consumer abandons it early.
201 */
202 export async function* coalesceChunks (
203 source : AsyncIterable < Uint8Array >,
204 { minSize , timeoutMs } : { minSize : number ; timeoutMs : number },
205 ) : AsyncGenerator < Buffer , void , undefined > {
206 const it = source[Symbol.asyncIterator]();
207 // The single in-flight pull. Read one ahead so we always have a promise to
208 // race the timer against; never start a second next() before this resolves.
209 let pull = it. next ();
210 let parts : Buffer [] = [];
211 let buffered = 0 ;
212 let deadline = 0 ; // ms timestamp; armed when the first byte enters an empty batch
213
214 const asBuffer = ( u8 : Uint8Array ) : Buffer =>
215 Buffer. isBuffer (u8)
216 ? u8
217 : Buffer. from (u8.buffer, u8.byteOffset, u8.byteLength);
218
219 const flush = () : Buffer => {
220 // One part: hand it back as-is (no concat, no copy). Many: join them.
221 const out = parts. length === 1 ? parts[ 0 ] ! : Buffer. concat (parts, buffered);
222 parts = [];
223 buffered = 0 ;
224 return out;
225 };
226
227 const take = ( u8 : Uint8Array ) : void => {
228 const b = asBuffer (u8);
229 parts. push (b);
230 buffered += b. length ;
231 };
232
233 try {
234 while ( true ) {
235 if (buffered === 0 ) {
236 // Nothing buffered: block for the next chunk with no timeout.
237 const r = await pull;
238 if (r.done) return ;
239 take (r.value);
240 deadline = Date. now () + timeoutMs;
241 pull = it. next ();
242 if (buffered >= minSize) yield flush ();
243 continue ;
244 }
245
246 // Below minSize with bytes in hand: race the next chunk against the time
247 // left on this batch's anchored deadline.
248 const remaining = deadline - Date. now ();
249 if (remaining <= 0 ) {
250 yield flush ();
251 continue ;
252 }
253 let timer : ReturnType < typeof setTimeout> | undefined ;
254 const timeout = new Promise < typeof TIMED_OUT >(( resolve ) => {
255 timer = setTimeout (() => resolve ( TIMED_OUT ), remaining);
256 });
257 const r = await Promise . race ([pull, timeout]);
258 clearTimeout (timer); // no-op if it already fired; frees the loop otherwise
259 if (r === TIMED_OUT ) {
260 // pull is STILL outstanding — keep it; just flush what we have so far.
261 yield flush ();
262 continue ;
263 }
264 if (r.done) {
265 yield flush (); // emit the tail; stream is over
266 return ;
267 }
268 take (r.value);
269 pull = it. next ();
270 if (buffered >= minSize) yield flush ();
271 }
272 } finally {
273 // Consumer broke out early (break/throw): let the source clean up.
274 if ( typeof it.return === "function" ) await it. return ();
275 }
276 }