feat: stream max and table-valued parameters from async sources - #1777
Conversation
…contract Splits parameter handling into two phases. `resolveParameter` validates the value and determines the declaration facts (length, precision, scale, collation) once, before a request is sent; `writeTypeInfo` and `writeValue` serialize the resolved parameter into a `WritableTrackingBuffer`. Types can implement `resolve`, `writeTypeInfo` and `writeValue` natively; the helpers adapt everything else from the existing `validate` / `resolve*` / `generate*` methods, so types can be migrated one at a time. Int, NVarChar and VarBinary are migrated. `Request.validateParameters` now resolves the request's parameters and keeps the result; the RPC payload takes resolved parameters and only serializes. `Connection.execSql`, `callProcedure`, `prepare`, `unprepare`, `execute` and the Always Encrypted metadata request build their payloads from resolved parameters. Bulk load writes column metadata and row values through the same helpers. Two behaviour changes come with the shared resolution: - Lengths are resolved for every type that can resolve one, not only for type ids matching the legacy variable-length bit pattern (the fix proposed in #1771). - Errors thrown while writing a parameter's TYPE_INFO are wrapped in the same `InputError` as errors from writing its value (the RPC half of #1772). A new unit test serializes 40 parameter cases across every type, on TDS 7.4 and 7.2, with and without a collation, through the new payload and through an inline copy of the previous serialization, and asserts the bytes are identical. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
Constraint names on temporary tables are unique per database, so the
named constraint collided when two CI jobs ran this test against the
same Azure database at the same time ("There is already an object named
'chk_id' in the database").
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
- `BulkLoad.addColumn` no longer gates length resolution on the legacy variable-length type id bit pattern, so the RPC and bulk load paths agree and #1771 is covered in full. - `resolveParameter` treats an explicitly specified length, precision or scale of 0 as specified instead of falling through to the type's resolver. Every existing resolver re-checked for an explicit value itself, so this changes no bytes for existing types; it removes the trap for a future type whose resolver does not. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
`resolveParameter` calls `type.validate(value, collation)` without the connection options, as every caller did before. Passing the options would activate the `useUTC`-dependent range checks in the date and time types, which have never run; that is a behaviour change to make on its own. A unit test pins the call shape. `Connection.resolveParameter` is renamed `resolveRequestParameter` so it is not misread as recursion into the free function it wraps. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
…quest The Always Encrypted metadata request is built from raw parameters rather than a `Request` that went through `validateParameters`, so the parameters are resolved inline. A comment says so. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
`RpcRequestPayload` wrote a small buffer per parameter and yielded each in turn. It now writes the whole request — header, and every parameter's header, TYPE_INFO and value — into a single `WritableTrackingBuffer` and yields its chunks once. The bytes are unchanged, and a large value written by reference is still referenced rather than copied (the tracking buffer references buffers of 8 KB or more), so this adds no copy; the request just reaches the packetizer as a few large chunks instead of a small buffer per parameter. Serializing a 20-scalar-parameter request is about 1.7x faster (benchmarks/parameters/scalar-params.js); large binary values, dominated by the value itself, are unchanged. The existing byte-equivalence and by-reference tests cover it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
A `varbinary(max)`, `varchar(max)` or `nvarchar(max)` parameter can now be given an async iterable of chunks instead of a whole buffer or string, and a table-valued parameter an async iterable of rows instead of a rows array. The value is read while the request is written, so a value larger than memory can be sent, and a large in-memory TVP is no longer copied in full before it is sent. The write contract gains `writeValueStream`: a type whose value is streamed (`ParameterData.streamed`) yields its length prefix and data as buffers, in chunks of its own choosing, reading the source as it goes. `resolve` detects an async source and marks the parameter streamed and `max`. `RpcRequestPayload` writes the request into one buffer as before; when a parameter is streamed it flushes what it has, delegates the value to `writeValueStream`, then continues. It exposes an async iterator only when a value is actually streamed, so a request of in-memory values keeps the fully synchronous serialization path unchanged. `max` values stream as PLP; TVP rows are validated and written as they arrive, flushing at packet-sized boundaries so memory stays bounded regardless of row count. Serializing a 1,000,000-row TVP given as an array is about 2x faster and uses far less peak memory (it is no longer buffered whole); the same TVP from an async iterable serializes at a similar rate with bounded memory. A failing source aborts the request with the `InputError` that names the parameter. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c5265dbf2c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Review: streaming
|
An async `varchar(max)` source may yield the two halves of a UTF-16 surrogate pair in separate chunks. Under a UTF-8 collation, encoding each chunk on its own turned each half into a replacement character, so a streamed value could differ from the equivalent in-memory value. The source is now re-chunked so a surrogate pair is never split across an encode boundary: a trailing lone high surrogate is carried into the next chunk. The result is byte-identical to encoding the whole string at once, for every codepage. nvarchar is unaffected — UCS-2 encodes each code unit independently. Reported by Codex review on #1777. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
Adds the unit tests suggested in review: - `declaration()` returns the `max` form for an async-iterable value, for the three `max` types. This is what `execSql` hands to `sp_executesql` as the parameter's type, and was previously only exercised by the `callProcedure` path. - A TVP whose async row source fails validation partway through surfaces the `InputError` naming the parameter, mirroring the `varbinary` case. Also notes, in `varbinary.resolve` and `TVP.resolve`, that a streamed value is always sent as `max` (overriding an explicit length) and that a TVP is always streamed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
|
Thanks — addressed the review:
On re-executing a Generated by Claude Code |
Review: streaming
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7274823e95
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The streamed max types and TVP flush their buffer once it reaches CHUNK_SIZE (8 KB), which is the memory-bounded core of the change. The existing tests only used small values, so the flush/consume bookkeeping was not exercised in CI. Adds: - a varbinary and an nvarchar source of 25/30 KB in several chunks, whose reassembled PLP data must equal the concatenated input; - a ~2000-row TVP (well past the flush size) serialized from an array and from an async source, asserted byte-for-byte equal. Also documents why `stitchSurrogates` only holds back a trailing high surrogate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
ReviewI went through the diff in detail (data-type.ts, plp-stream.ts, varbinary/varchar/nvarchar.ts, tvp.ts, rpcrequest-payload.ts, and the new tests) rather than just skimming. No CLAUDE.md exists in this repo, so I reviewed against the conventions already established by the surrounding code (the OverallThis is a well-scoped, carefully engineered addition. A few things stood out positively:
Minor / non-blocking observations
Security / correctnessNo injection or memory-safety concerns — this is purely wire-format serialization, buffers are written by length-prefix, and streamed sources are read, not Test coverageStrong — Nothing here blocks merging as far as I can tell — the observations above are polish/robustness suggestions rather than bugs. |
|
Thanks — addressed the two you'd want resolved before merge:
Also added the On the deferred points: the dead Generated by Claude Code |
Adds a CP932 (non-UTF-8, double-byte) case to the surrogate-splitting test, so the "byte-identical to encoding the whole string, for every codepage" guarantee is self-verified in CI rather than resting on manual checks. Also documents why `RpcRequestPayload`'s `[Symbol.asyncIterator]` field is a conditionally-assigned own property rather than a `declare`d field. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
ReviewWent through the diff in detail (data-type.ts, plp-stream.ts, varbinary/varchar/nvarchar/tvp.ts, rpcrequest-payload.ts, and both test files). Overall this is a clean, well-scoped extension of the write contract from #1776, and it's unusually well tested for a feature this size. A few notes below, nothing blocking. Code quality / best practices
Potential bugs / issues
Performance
Security
Test coverage
Nice work overall — the stacking on #1776, the byte-equivalence testing strategy, and the surrogate-pair fix in particular show careful attention to correctness. |
A TVP row's metadata covers every declared column, so a row with fewer (or more) values than columns desyncs the server's parse of the following rows. The row loop only iterated over the values present, so a short row was serialized against the wrong column metadata. `validateRow` now rejects a row whose length does not match the column count, on both the array and async paths, with an `InputError` naming the row. Valid rows are unaffected and serialize byte-for-byte as before. Reported by Codex review on #1777. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
|
Thanks for the thorough pass. Applied the two test/robustness items:
Also, separately, Codex flagged (and I fixed in d3c298a) a mismatched-TVP-row-length case — On the two notes I left as intentional tradeoffs:
519 unit tests passing, lint + typecheck clean. I think that covers the actionable findings from both reviews; the remaining points (dead-code cleanup, Generated by Claude Code |
|
Thanks — checked the new points:
The two test suggestions I'll leave as optional follow-ups rather than push again:
I've now worked through every actionable finding across the review rounds (surrogate splitting, flush-boundary coverage, TVP row-length mismatch, codepage coverage) and both bots agree nothing blocks merge, so I'll stop iterating here to avoid re-triggering reviews on each push. Remaining items are the follow-ups listed in the description. 519 unit tests passing, lint + typecheck clean. Generated by Claude Code |
ReviewI read through the diff against the stacked base (#1776) end-to-end: Correctness
Minor observations (non-blocking)
Test coverageCoverage looks thorough: streamed-detection per type, Security / PerformanceNo security concerns — this doesn't change validation semantics, only how already-validated bytes reach the wire, and the Nice work — this is a substantial, carefully-tested change and I didn't find any correctness issues. |
ReviewI traced the resolve →
A few things worth a look before merging:
Nothing here blocks merging in my view — items 1–3 are documentation/coverage gaps, and 4 is a defensive nice-to-have. Nice work on keeping the non-streaming path byte-for-byte identical while adding this. |
Addresses review feedback on the streaming path: - `writeValueStream`'s doc notes that a large buffer (CHUNK_SIZE or more) is referenced rather than copied, so a source must not reuse or mutate a buffer it has yielded until the request is sent. - `RpcRequestPayload.generateData` (the synchronous iterator) now throws if the payload contains a streamed parameter, turning a misuse (iterating a streamed payload synchronously instead of via `Readable.from`) into a loud error rather than corrupted bytes. Not reachable through the current call sites, which all use `Readable.from`. - Adds a test for a single streamed chunk at or above CHUNK_SIZE, exercising the zero-copy `writeBuffer` branch directly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
|
Good set of points — applied all three concrete ones in 29122b7:
Points 3 (an 520 unit tests passing, lint + typecheck clean. That closes out the actionable review feedback for me — thanks for the careful passes. Generated by Claude Code |
ReviewWent through the full diff ( Strengths
Suggestions (non-blocking)
Other
Nice work — this is a substantial capability add (unbounded-size params, ~2x/lower-memory TVP serialization) landed with a genuinely thorough test suite. |
|
Thanks — these are all fair, and none are correctness issues, so I'm going to hold the branch here rather than push another round (each push re-triggers the bot reviews, and the real findings across the earlier rounds — surrogate stitching, TVP row/column desync, flush-boundary coverage, the by-reference contract and sync-path guard — are all fixed and tested). Recording my take on each for whoever does the human review:
So: 1 and 3 are the two I'd genuinely want, and I think both belong in a small follow-up rather than more commits here. If you'd prefer any of them in this PR instead, say which and I'll add it. 520 unit tests passing, lint + typecheck clean. Generated by Claude Code |
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
ReviewThis is a well-designed, well-tested addition on top of the Code quality / design
Potential edge cases (minor, non-blocking)
PerformanceMatches expectations: no extra copy for buffer-backed values, bounded memory for streamed sources, and the benchmark numbers in the description (added SecurityNo concerns — parameterization is preserved throughout (values still flow through the same type validators before being written), no new string concatenation into SQL text, and the collation/encoding checks fail closed (throw) rather than silently mis-encoding. Test coverageThis is thorough: byte-equivalence between streamed and in-memory serialization, empty-chunk skipping, chunks that straddle the Nothing here blocks merging; the two minor points above could be follow-ups rather than changes to this PR. |
A type streaming a value allocated its own tracking buffer, knew the flush threshold, called getBuffers and consume itself and yielded buffers, so the flush logic existed once per type, the payload had to flush before delegating to keep the two byte streams apart, and both sides handled a sync-or-async union. writeValueStream now takes the buffer writeValue writes into and yields whenever that buffer holds a chunk's worth; the payload owns the one flush. A type writes exactly as in writeValue plus one yield, the request is one buffer from header to last parameter with no partial chunks at a streamed value's edges, and the signature is async-only. A TVP given an array of rows keeps its synchronous loop inside the async generator, so it still yields per chunk rather than per row. Throughput is unchanged for scalar requests and array TVPs and about 10% better for a TVP from an async source. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
Review: streaming
|
…ference The tracking buffer references rather than copies a buffer of CHUNK_SIZE or more, so a source that reuses one scratch buffer across reads would have it overwritten before the bytes leave. The contract was documented for type implementers on writeValueStream but not for callers on addParameter. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
Review: streaming
|
resolve already rejects a streamed varchar without a collation or codepage, and writeValueStream is only reached with a resolved parameter, so the second check could not fire. The streamed field's doc now says a type that resolves a value as streamed must implement writeValueStream, and a test covers a TVP row that is not an array. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
|
Reviewed the diff (12 files, ~1100 additions). This is a clean, well-scoped addition on top of the Correctness
Minor/non-blocking observations
Overall: solid design, the shared-buffer/async-generator restructuring of |
The test only compared bytes; the by-reference claim is now checked by identity on the yielded buffers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
ReviewThis is a well-scoped, carefully engineered PR. The split between Correctness
Code quality
PerformanceMatches the PR description's own measurements (single flush buffer per request, by-reference passthrough for large chunks/TVP rows preserved). Nothing to add here — the benchmark additions ( SecurityNo issues spotted. Chunk lengths are always the actual byte count of encoded data (no user-controlled length fields written independently of the data itself), and the streamed cases are still subject to the same Test coverageStrong — unit coverage for Nice work overall — this is a substantial change handled with a lot of care for byte-level compatibility. |
…the live path generateParameterLength, generateParameterData, generateTypeInfo and validate are no longer reached when a TVP is serialized, since resolve always marks it streamed and writeTypeInfo/writeValueStream are native, but the DataType interface still requires them. They carried their own copy of the column metadata, row and type-info serialization, and validate repeated resolve's table check. They now delegate to the helpers the live path uses, so the bytes cannot drift, and resolve and validate share one table check. The legacy row path also gets the row-length check the live path has. Deleting the methods waits for the interface to drop the legacy trio, once every type has migrated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
ReviewThis is a well-scoped, carefully documented PR that extends the resolve/write contract from #1774 with a genuinely useful capability (streaming A few things worth a look before merging: Correctness / robustness
try {
writeTypeInfo(parameter.type, buffer, parameter.data, this.options);
if (parameter.data.streamed) {
for await (const _ of parameter.type.writeValueStream!(buffer, parameter.data, this.options)) {
yield * buffer.getBuffers(); // <-- yield happens inside the try
buffer.consume(buffer.length);
}
} else {
writeValue(parameter.type, buffer, parameter.data, this.options);
}
} catch (error) {
throw new InputError(`Input parameter '${parameter.name}' could not be validated`, { cause: error });
}Previously (pre-PR), In practice this is low-risk today: Minor / non-blocking
Test coverageCoverage is strong: byte-for-byte equivalence for existing scalar/TVP serialization, streamed vs. in-memory equivalence for all three Performance / securityNo concerns. The chunk-size-based flush/by-reference threshold reuse from Nice work overall — this is a substantial extension implemented with a lot of care for backward compatibility and test rigor. |
The streamed branch consumed the type's writeValueStream with a `for await` inside the `try` whose catch relabels errors as the parameter's, so the yields to the consumer sat inside it too: an error a consumer threw into the suspended generator would have come back as "Input parameter 'x' could not be validated". The type's generator is now driven by hand, with only its next() wrapped, the yields outside, and a finally that closes it, and with it the value's source, when the consumer stops early, as `for await` would. Tests cover both the early stop and a consumer-thrown error keeping its identity. Throughput is unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
An array iterator has no throw method, so an error a consumer throws into the generator while it delegates to one with yield* surfaces as "TypeError: The iterator does not provide a 'throw' method" on Node 24 and later, which follow the spec here; Node 22 still forwarded the consumer's error, which is why the test added with the previous commit passed locally and failed in CI. The chunks are now yielded from a loop, as BulkLoadPayload does, so a thrown error resumes the generator at a plain yield and keeps its identity on every Node version. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
Review: Streaming parameter values (async iterable support)This is a well-designed, carefully-scoped addition -- Correctness
Design / code quality
Performance
Security
Minor nit
Overall this looks solid and ready modulo the two flagged points above (neither of which looks blocking). |
ReviewThis is a well-designed and unusually thoroughly-tested change. A few observations by category: Architecture / code quality
Potential bugs
Performance
Security
Test coverage
Overall this looks like solid, careful work — happy to see this land pending CI. |
The other streamed types return a generator without doing any work first; VarChar read the codepage before returning one, the only synchronous step a streamed write took outside the payload's wrapped next(). It is an async generator now, so nothing runs before the first next() for any type. A test covers a TVP whose rows are neither an array nor an async iterable. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
Every TVP row allocated a validated copy of itself, and every cell a fresh ParameterData object. Validation and writing now happen cell by cell in one pass, and the cell objects are created once per TVP and reused for every row, with only the value reassigned: a type writes a cell's bytes as soon as it is handed the cell and keeps no reference to it. A cell that fails validation leaves the row half written in the buffer, which does not matter, since the request is abandoned with the error and that buffer never reaches the wire. The row-length check still runs before any byte is written. Rows serialize 10-15% faster, from an array and from an async source alike. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
ReviewI read through the diff ( Overall this is a well-designed, well-tested change. A few observations, nothing blocking: Correctness
Minor/consider
Test coverage
Performance
No blocking issues found; nice work on a genuinely tricky piece of async-generator plumbing. |
ReviewThis is a well-engineered change: the streaming contract is minimal (one optional A few things worth a look, roughly in order of importance: 1. A source's own cleanup failure can mask the real error (low severity)In } finally {
if (!done && typeof flushes.return === 'function') {
await flushes.return();
}
}If the consumer throws into the payload (or otherwise causes early exit) while a streamed value is in flight, and the value's own source throws from its 2. Minor asymmetry between the three
|
… fails The payload closed the type's generator in a finally, so a source whose cleanup rejected when it was closed early replaced the error that was propagating, e.g. the one the consumer had thrown into the generator. `for await` keeps the original error in that case, and BulkLoadPayload already does. The close now happens in the catch with its own failure swallowed, and the finally only covers the consumer returning early, where nothing else is propagating. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
ReviewThis is a very well-executed PR — I read through Code quality / design
Potential bugsNothing I'd block on. A few minor things worth a look, not necessarily in this PR:
I also specifically checked two adjacent subsystems that the PR body doesn't mention:
PerformanceThe measurements in the PR body are credible and the design rationale (single buffer per request, cell reuse, streaming flush at SecurityNothing concerning. Errors from a failing source are wrapped in Test coverageExcellent. Unit tests cover resolve/streamed-detection, the Nice work overall — a substantial, carefully-sequenced change (visible in how many commits are review-driven fixes for exactly the kind of generator-delegation/error-identity subtleties that are easy to get wrong), and the test suite gives real confidence in it. |
A value read from a source needed a second method, writeValueStream, and a streamed flag on the resolved parameter to pick it, and the loop that drives such a write, with its close-on-early-stop and error identity rules, lived in RpcRequestPayload alone. writeValue now covers both: a value fully in memory is written before it returns, a value read from a source returns the rest of the write as an async iterable that yields whenever the buffer holds a chunk's worth. writeValueStream and ParameterData.streamed are gone; resolve still declares an async source as a max type. The driving loop moves into writeRest, shared by everything that writes a value. VarChar gets its in-memory writeValue, ported from its generate* methods, to host its streamed branch. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
ReviewThis is a well-scoped, carefully engineered addition on top of #1774's write contract. The design — Bug: stale reference to a removed API name
if (!Array.isArray(value.rows)) {
throw new TypeError('A TVP whose rows are an async iterable can only be written through writeValueStream.');
}
Things that look right (called out because they're easy to get wrong)
Minor/non-blocking observations
Test coverageSolid. Unit tests cover the streaming contract in isolation (flush boundaries, by-reference buffers, error propagation/identity, early stop, TVP validation edge cases), and the integration suite exercises the real wire path for all three Security / performanceNo concerns. This is additive (opt-in via async iterables) and the perf numbers in the PR description (measured, not just claimed) show it's a net win even on the non-streaming path due to the single-buffer serialization. |
writeValueStream no longer exists. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
ReviewI read through the full diff (12 files, ~1.3k additions) plus the commit history, which already shows several rounds of fixes for issues an earlier Codex review caught (surrogate-pair splitting, TVP row/column-count mismatch, error identity across Design / code quality
Potential edge cases considered (no bug found)
Test coverageCoverage looks strong: unit tests exercise Minor / non-blocking
Overall this looks solid and well-tested. Nice work threading the "no second copy, bounded memory, byte-identical output" needle while keeping the public API additive (existing in-memory callers are unaffected). |
|
🎉 This PR is included in version 20.3.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
Problem
Every parameter value has to be fully in memory before a request is sent. A
varbinary(max)must be oneBuffer, annvarchar(max)one string, a table-valued parameter an array of rows. So a value larger than memory cannot be sent at all, and a large in-memory TVP is serialized into a second full copy before any of it reaches the socket.The write contract from #1774 was built for this: parameters are resolved before the request starts, and types write into a buffer. This adds the streaming half.
Change
DataType.writeValuenow covers a value read from a source as well as one in memory. A value that is fully in memory is written before it returns, as before. A value read from a source while the request is written (an async iterable) returns the rest of the write instead: an async iterable that yields whenever the buffer holds a chunk's worth (WritableTrackingBuffer.CHUNK_SIZE) so the caller can hand those bytes on before the rest of the value is read, and nothing of that rest runs before its firstnext().resolvedetects an async source and declares the parameter as amaxtype, since its length is not known up front. There is no second method and no flag on the resolved parameter: the type decides per value, and the caller decides by the return.varbinary(max)/varchar(max)/nvarchar(max)accept an async iterable of chunks (buffers, or strings encoded per the type) and write them as PLP through one shared helper. VarChar gets its in-memorywriteValue, ported from itsgenerate*methods, to host its streamed branch.writeValuealways returns the rest of the write; rows given as an array are written in a synchronous loop inside it, so the only asynchrony is the yield per chunk's worth of rows, not per row.writeRest(rest, wrap)indata-type.tsdrives such a rest for whoever calledwriteValue: only the rest's ownnext()is wrapped throughwrap(the payload names the parameter), an error thrown into the driver by its consumer keeps its identity, a consumer that stops early closes the rest and with it the value's source asfor awaitwould, and a close that fails does not replace a propagating error.RpcRequestPayloadbecomes an async iterable, the same shape asBulkLoadPayload(#1779): one generator writes the request header and each parameter's header, TYPE_INFO and value into oneWritableTrackingBuffer, and yields the buffer's contents whenever it holds a chunk's worth (checked after every parameter, and at every yield of a streamed value's rest) and at the end. The whole request is one buffer from header to last parameter, with no partial chunks at a streamed value's edges. Bytes are unchanged, a large value written by reference stays by reference, and the first packet can leave before the last parameter is written. Chunks are yielded one by one rather than throughyield*, since an array iterator has nothrowmethod and a consumer's thrown error would otherwise become a TypeError on Node 24+. Compared withmaster's small buffer per parameter, a request of 20 scalar parameters reaches the packetizer as 4 chunks instead of 21 and serializes about 1.45x faster (see Measurements). This folds in #1776, which is closed in favour of this PR.makeRequestalready consumes the payload throughReadable.from, which drove the previous synchronous iterator through its own asynchronous read loop anyway, so an always-async payload costs nothing on the scalar path (an earlier revision of this PR kept a synchronous iterator for requests without a streamed value and measured the same).The TVP's legacy
generateTypeInfo,generateParameterLength,generateParameterDataandvalidate, still required by theDataTypeinterface but no longer reached when a TVP is serialized, delegate to the same column, row and type-info writers as the live path (verified byte-identical for a null and an array table), andvalidateandresolveshare one table check.Streaming flushes at chunk-sized boundaries, so the number of yields is proportional to the byte size, not the row/chunk count, and memory stays bounded regardless of how large the source is. A source that throws mid-stream aborts the request with the same
InputErrorthat names the parameter.Behaviour
maxvalues: byte-for-byte identical. The 40-case byte-equivalence suite consumes the payload asynchronously, so it also covers the TVP path.Readable) as amaxvalue, or as a TVP'srows, streams it. This works throughexecSql,callProcedureand a prepared statement'sexecutealike, since all three resolve their parameters the same way.Request.addParameter's doc comment describes the form.Requestcarrying a streamed value can be sent once — a one-shot source (a consumedReadable, a generator) cannot be replayed the way a buffer/string can.Writable.writeencodes it: a chunk must not end halfway through a UTF-16 surrogate pair. Node.js core does not stitch pairs across writes anywhere (Writable.write,fs.createWriteStream,crypto.Hash.updateall produce two replacement characters for a split pair), and this PR follows that rather than re-chunking strings itself. Text Node decoded from UTF-8 never splits a pair; a string sliced by index can. ABufferchunk of 8 KB or more is sent by reference, so a source must not reuse or modify it until the request has completed. Both documented onaddParameter.RpcRequestPayloadis internal, but for anyone iterating it directly: it is now an async iterable only.DataType.writeValue's return type widens fromvoidtovoid | AsyncIterable<void>; a caller that ignored the return keeps working for in-memory values.Validation
test/unit/streaming-parameters-test.ts:resolvedeclaring an async source asmaxandwriteValuereturning the rest for it,declaration()returning themaxform for an async source, the payload handing out a full chunk before it writes the next parameter, PLP output (including empty-chunk skipping, and a chunk ofCHUNK_SIZEor more handed on by reference),InputErrorpropagation from a failingmaxor TVP source, the value's source being closed when the consumer stops early, an error the consumer throws into the payload keeping its identity even when closing the source fails as well, a TVP row that is not an array or has the wrong length, a TVP whose rows are neither an array nor an async iterable, and a TVP from an async iterable serializing identically to the same rows as an array (including a ~2000-row case past the flush size, and an async iterable that yields no rows).test/integration/streaming-parameters-test.ts(against a real server): a streamedvarbinary(max),nvarchar(max)andvarchar(max)value round-trips unchanged (100 KB, uneven chunks including empty ones, crossing packet and flush boundaries), throughexecSqland through a prepared statement'sexecute; a 5,000-row TVP fed from an async iterable arrives intact; a source that throws after data is already on the wire, and a TVP whose streamed row fails validation, both surface as the parameter'sInputErrorand leave the connection usable for the next request. The TVP tests name their type and procedure per test, since the Azure CI jobs share one database.maxtype, a streamed value and the equivalent in-memory value produce the same server-sideHASHBYTESandDATALENGTH(3 MB binary, 400 KB nvarchar, 240 KB varchar), and empty streamed values send as zero-length.Measurements
Serialization only, no server: each request is resolved, serialized and consumed through
Readable.from(payload)into a no-op sink, asmakeRequestdoes.masterat 563d37b (with #1774) against this branch at 231cc68, same machine and run, Node 22, median of 5–9 runs, each pair measured twice back to back.mastervarbinary(max)The scalar gain is the single buffer: one flush per request instead of one small buffer per parameter. The large-
maxcase is a by-reference write on both sides; both spread from ~30k to ~190k req/s across runs depending on GC, so the medians move either way from run to run. The array TVP no longer builds a second full copy of the rows before the first byte goes out and allocates nothing per row or cell; the async source pays for a promise per row and still streams with bounded memory.Where the remaining TVP time goes: the same 1M-row benchmark with the
bitcolumn replaced by a secondintruns at ~3.9M rows/s (array) and ~1.9M rows/s (async).Bitstill serializes through the legacygenerateParameterLength/generateParameterDatapath, which allocates a buffer and a generator per cell; that is the per-family migration of the remaining types, planned after this PR. #1780, stacked on this one, compiles a writer per column on top of this contract.The repo's own
benchmarks/parameters/scalar-params.js,tvp-rows.jsandtvp-rows-async.jsmeasure the same three shapes.Follow-ups (not in this PR)
generate*methods andvalidateare dead on the live path and now only wrap the shared writers; deleting them means making the legacy trio optional onDataType, which touches every type's tests and belongs with the end of the per-family migration.BulkLoadPayloadstill allocates a parameter object per cell; feat: compile one writer per parameter or column, and read a row's max cells from a source #1780 replaces that with a compiled writer per column.#1774 has merged, so the diff here is the streaming work itself (12 files). #1772 is independent of this PR and goes in first; this branch takes
masteragain after it lands.🤖 Generated with Claude Code
https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug