Repository navigation
perf: serialize bulk load rows with an async generator, coalesced into packet-sized chunks - #1779
Conversation
…o packet-sized chunks RowTransform pushed COLMETADATA, every row token, every length prefix and every data chunk downstream as its own buffer, and each row passed through Readable.from, pipe, a Transform with a nextTick callback, that transform's async iterator and Readable.from(payload) before reaching the packetizer. BulkLoad.serializeRows is an async generator that writes COLMETADATA, the rows and DONE into one WritableTrackingBuffer and yields its contents once a chunk's worth (8 KB) has accumulated, checked after every cell, or as soon as an asynchronous source goes idle (a source may be waiting on the server's reaction to rows it already produced, so they cannot be held back). Rows from a synchronous source are serialized without an event-loop turn in between. BulkLoadPayload hands the rows to the generator, so the payload reaches the outgoing message the way RPC payloads do. The pipe plumbing in execBulkLoad is gone, and so is streamingMode, which only kept the transform open. What Readable.from used to provide is kept: a row given as a promise is settled before it is serialized, a stream source keeps an error it emits while the INSERT BULK statement is in flight instead of raising an unhandled 'error' event, and an iterable whose async-iterator property is undefined is read synchronously. A bulk load with no rows now works: COLMETADATA is written before the rows are read, where the transform wrote it lazily and sent only DONE, which the server rejects. Co-Authored-By: Claude Fable 5.1 <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: 0d0a3d81d6
ℹ️ 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".
ReviewThis is a well-executed refactor. Collapsing the Correctness
Minor / nice-to-have
PerformanceThe measurements are credible and match the architectural change (removing several stream/pipe/nextTick hops per row). The ~1µs/row cost for the idle-flush race on async sources is a reasonable tradeoff for not stalling low-throughput producers, and it's called out transparently in the PR description. SecurityNothing of concern — this is internal wire-format serialization with no new external input handling or injection surface. Test coverageExcellent. The unit suite covers chunk-boundary flushing, byte-for-byte equivalence against per-row serialization (both from an array and an async source), the empty-bulk-load fix, promised rows (including rejection), stream error retention before rows are read (plus listener cleanup verified against a plain Nothing here blocks merging — just the small dead-code/type-duplication notes above for consideration. |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #1779 +/- ##
==========================================
+ Coverage 81.89% 82.27% +0.37%
==========================================
Files 92 92
Lines 4950 4969 +19
Branches 933 940 +7
==========================================
+ Hits 4054 4088 +34
+ Misses 601 593 -8
+ Partials 295 288 -7 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…ver a close failure The rows are read only once the server has accepted the bulk load, and from the first row on the source is closed the way a `for await` loop closes it, through its iterator's `return()`. An iterator that never started has no `return()` to run, so a source the bulk load never got to read remains the caller's to close. The execBulkLoad doc comment now says so, with an async generator that opens its file lazily and a stream created up front that the callback destroys. A row failure closes the source before the error surfaces; a close that fails no longer replaces the row error. Two unit tests pin that, and that a close failure is still reported when there is no row error. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 27265c568a
ℹ️ 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".
ReviewThis is a well-scoped, well-tested refactor. The rationale in the description is clear and the byte-equivalence testing strategy (serializing the same rows per-row vs. through the generator and diffing the output) gives good confidence the wire format is unchanged. CorrectnessI traced through
I didn't find a correctness bug in the new serialization path. Minor / nit-level observations
PerformanceThe design (accumulate into one long-lived One thing I couldn't verify without running the fake-server cancel/pause-resume suites myself: the PR description says these already pass against SQL Server 2022, so I'll trust that over re-deriving it from the diff. SecurityNo concerns — this is purely a transport-serialization refactor with no new external input parsing, and the byte layout is asserted to be identical to the previous implementation. Test coverageVery thorough for the new code path: chunk coalescing, mid-row chunk boundaries, idle-triggered flushes, synchronous-vs-asynchronous sources, byte-equivalence against per-row serialization, empty bulk loads, promise-valued rows, sync iterables with Review generated by Claude Code. |
Nothing has ever set it: it dates from the removed addRow API. The two guards it fed (no columns after the first row, no execBulkLoad with rows after rows were written) could not fire, and the check around validate meant every row was validated, which every row still is. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
ReviewThis is a well-scoped and well-documented change. Replacing the The empty-bulk-load fix and the removal of the dead Possible issue: stray
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 899fca7580
ℹ️ 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".
Rows from an asynchronous source were raced against a setImmediate so that what had accumulated went downstream as soon as the source stalled. That bought nothing: the packetizer below only sends whole packets and holds a partial one until the message ends, so a small row stalled in the serializer and one stalled in the packetizer are equally invisible to the server, and the server sends nothing back before DONE that a source could be waiting on. It cost a Promise.race per row. The read loop is now an await per pending read and per promised row. The fake-server cancel tests yielded one 8000-byte row before stalling, just short of a chunk; they yield two so the message starts going out. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d10126d438
ℹ️ 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".
ReviewWent through all four commits and the final state of Correctness
Minor/nitpick (perf, since that's the whole point of the PR)
Test coverageThorough — byte-equivalence against the old per-row serialization (from both an array and an async source), chunk-boundary edge cases (mid-cell, mid-row, null text cells), the empty-bulk-load fix, promised rows, stream error timing, close-failure-doesn't-hide-row-error (and the reverse), and the synchronous-source-no-event-loop-turn guarantee. The fake-server cancel tests were correctly updated (2×8000-byte rows instead of 1, since a single row no longer forces a flush without the idle-flush logic removed in the last commit) with a comment explaining why. Nothing else stood outNo security concerns (this is TDS wire serialization, not string-built SQL). No leftover references to the removed Nice work — the row-source-closing contract in particular is a genuinely subtle area (promises vs. sync/async iterables vs. streams, plus who owns closing an unstarted source) and it's handled carefully and documented well enough that a caller can actually reason about it. |
…t loop would The payload obtains the row source's iterator when it is created and requests the first row right away, the rest once the server has accepted the INSERT BULK statement. A stream source is thereby observed from the moment it is handed over: an error it emits while the statement is in flight is kept by its iterator and reported on the next read, so the payload no longer needs a listener of its own, or to know whether it holds a stream at all. Having started the iterator, the payload always owns closing it, and always through the same call, return(): while the rows are being read, when a row fails or the consumer stops; and, through close(), when the INSERT BULK statement is rejected, the bulk load was canceled, or the connection is no longer logged in, the cases execBulkLoad's callback sees before makeRequest would read the payload. For a Node stream that destroys the stream. A read still pending in the source cannot be interrupted; the source is closed once it has settled. The serialization moves from BulkLoad.serializeRows into the payload, which owns the iterator it reads, so bulk-load.ts is back to columns, COLMETADATA and DONE. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
ReviewNicely scoped rewrite — replacing One correctness concern and a couple of minor nits below. Correctness: a synchronous row source can now throw out of
|
…load, not execBulkLoad Requesting the first row when the payload is created meant a synchronous source that throws before its first yield threw out of execBulkLoad itself, where master surfaced every source error through the bulk load's callback. The throw is now held like a rejected read and reported when the rows are serialized. Also hoists the per-cell text-type check to once per column and the array check to once per row, and has execBulkLoad's signature reuse the payload's Row type. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
ReviewI read through Code quality
CorrectnessI didn't find a concrete bug. A few edge cases I specifically checked and believe are handled correctly:
One behavioral tradeoff worth confirming is intentionalThe chunk threshold is Minor nit
PerformanceThe measurements in the description (roughly 2× on the pure serializer, and consistent double-digit percentage gains end-to-end against SQL Server) are convincing, and the byte-equivalence test against the old per-row serialization approach is a good way to pin behavior while changing the mechanism. SecurityNo concerns — no new external input parsing, buffer bounds are unchanged from the prior per-row serialization, and closing row sources more consistently is a (minor) improvement for resource hygiene (e.g., a rejected Test coverageThis is a strong point of the PR: chunking behavior, byte-for-byte equivalence (sync array vs. async source), first-row eager read timing, synchronous-source throw semantics, promise-settling, empty bulk loads, text-pointer/null encoding, and the various source-closing scenarios (row failure, early consumer stop, close failure not masking a row error, close failure being reported when there's no row error) are all covered at the unit level, with integration tests for the empty-bulk-load and cancel/rejection paths. I'd call this thorough. Overall this is a well-reasoned and well-tested simplification with a measured performance win; I didn't find correctness issues, just the one tradeoff worth a sentence of documentation and the minor single-consumption nit above. |
Takes the bulk load serializer from #1779. The transform this branch had routed cells through writeValue is gone; the payload's loop routes them through writeValue instead, and COLMETADATA keeps going through writeTypeInfo. The wide-row unit test spies on writeValue now that VarBinary implements it natively. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
…ters Brings in the merge of master (#1779's bulk load serializer) from the parameter-contract branch. No conflicts: this branch does not touch the bulk load. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
…ror-handling Takes the bulk load serializer from #1779 via the parameter-contract branch. The InputError wrapping this branch put around the transform's writeValue moves into the payload's loop; the wrapping around writeTypeInfo in getColMetaData is unchanged. COLMETADATA is now written inside the payload's try, so a TYPE_INFO failure closes the row source like a row failure does. The serialization error tests drive the payload instead of the transform. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
|
🎉 This PR is included in version 20.2.3 🎉 The release is available on: Your semantic-release bot 📦🚀 |
Replaces #1778, which grew far past the change it set out to make. This is the original simplification with only the behaviour
Readable.fromused to provide added back, and the ownership of the row source made explicit.Problem
RowTransformpushes COLMETADATA, every row token, every cell's length prefix and every data chunk downstream as its own buffer. A bulk load of small rows reaches the packetizer as many tiny chunks per row, and each row passes throughReadable.from(rows),pipe, aTransformwith anextTickcallback, that transform's async iterator, andReadable.from(payload)before it gets there.Change
RowTransformis replaced byBulkLoadPayload, an async iterable that serializes the rows into oneWritableTrackingBufferand yields its contents once it holds a chunk's worth (WritableTrackingBuffer.CHUNK_SIZE, 8 KB), checked after every cell so a wide row goes downstream as it is serialized, and at the end. The bulk load payload reaches the outgoing message the way RPC payloads do. The pipe plumbing inexecBulkLoadis gone, and so isstreamingMode, which only existed to keep the transform open.Rows from a synchronous source (an array, a generator) are serialized without an event-loop turn in between. Rows from an asynchronous source are awaited one at a time. There is no flush on a stalled source: the packetizer below only sends whole packets and holds a partial one until the message ends, so bytes stalled in the serializer and bytes stalled in the packetizer are equally invisible to the server, and the server sends nothing back before DONE that a source could be waiting on.
The row source
The payload owns the row source the way a
for awaitloop owns what it iterates, which is also whatpipedid onmaster. It obtains the iterator when it is created and requests the first row right away; the rest are read once the server has accepted theINSERT BULKstatement. Requesting the first row is what attaches aReadable's own listeners, so an error the stream emits during the round trip is kept and reported on the next read, with no listener of ours and noinstanceofon the source. A synchronous source that throws on that first read is held the same way as a rejected one and reported through the bulk load's callback, as every source error was onmaster.Having started the iterator, the payload always closes it, always through
return(): when a row fails, when the consumer stops because the bulk load was canceled, and (throughclose(), called fromexecBulkLoad's callback) when theINSERT BULKstatement is rejected, the bulk load was canceled before the rows were sent, or the connection is no longer logged in. For a Node.js stream,return()destroys the stream. A read still pending in the source cannot be interrupted; the source is closed once it has settled. This is documented onexecBulkLoad, with an async generator that opens its file lazily and closes it in itsfinallyas the recommended shape.masterclosed the source only on cancel; a bulk load that failed before its rows were sent left the piped stream open.Kept from what
Readable.fromused to do: a row given as a promise is settled before it is serialized, and an iterable whose async-iterator property isundefinedis read through its synchronous iterator.Two fixes fall out:
return()rejects after a row failed, the row error is still what the bulk load fails with.Also removed:
firstRowWritten, a@privateflag left over from theaddRowAPI that nothing ever set. The two guards it fed could not fire, and the check it put aroundvalidatemeant every row was validated, which every row still is.The bytes for a bulk load with rows are unchanged. A unit test serializes 300 rows of
int, nullablenvarcharandvarbinaryonce per row through the samegenerate*methods and once through the payload, from an array and from an async source, and asserts the byte streams are identical.rowToPacketTransformno longer exists. It was@private, and there was no supported way to feed it rows directly.bulk-load.tsis back to columns, COLMETADATA and DONE, and shorter than onmaster.The three fake-server cancel tests yielded one 8000-byte row before stalling, relying on the transform passing it straight to the packetizer. That is a few bytes short of a chunk, so they yield two rows now; their intent, that the server has started receiving the message when the stall begins, is unchanged.
Measurements
Measured against
master(daa8e77), same machine, same run.Serializer in isolation, in the pipeline shape
makeRequestuses (rows → serializer →Readable.from(payload)→ sink), 200,000 rows from an array, median of 5 runs:masterint,nvarchar(50),floatintEnd to end against SQL Server 2022, 50 bulk loads of 10,000 rows each:
masterintcolumn, arrayValidation
CHUNK_SIZEper chunk, far fewer chunks than rows, a chunk mid-row, null text cells), byte equivalence with per-row serialization from an array and an async source, the first row requested at creation and no more, a synchronous source serialized without an event-loop turn, the empty bulk load's bytes, promised rows, a stream error before the rows are read, a synchronous source's throw on its first read reported through the iteration rather than the constructor, theundefinedasync-iterator property, objects vs arrays, text pointers, the source closed when a row fails, when the consumer stops early, and when closed unread (a stream, a generator, and one whose close throws), a close failure neither hiding a row error nor going unreported when there is none, andexecBulkLoadclosing the source when the connection is not logged in.INSERT BULKstatement is rejected, when the bulk load was canceled before it, and when it is canceled right after. Bulk load, pause/resume and cancel suites pass against SQL Server 2022.🤖 Generated with Claude Code
https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug