perf: rewrite WritableTrackingBuffer as a chunked buffer list - #1773
Conversation
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: 9cdd6e3b81
ℹ️ 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".
`WritableTrackingBuffer` grew by concatenating everything written so far into a composite buffer on every growth step, and `data` returned that composite. Every growth was a full copy, and callers worked around it by computing exact sizes up front. It is now a write-side buffer list: bytes are written into an open chunk that starts at 64 bytes and doubles up to 8 KB, sealed chunks are kept in a list, and buffers of 8 KB or more are referenced rather than copied. Short UCS-2 strings are encoded in JavaScript, avoiding the per-call cost of the native encoder that dominates for short strings. The list API (`append`, `length`, `getBuffers`, `consume`, `slice`) mirrors `bl`'s `BufferList`; `data` stays and is a view when everything fits into a single chunk. Cleanups that go with the new storage model: - The constructor takes no arguments. The initial size was a hint that cannot be wrong under the chunk model and measurably does not matter; the growth flag is meaningless without growth-by-concatenation. The size computations at the call sites are removed. - No instance-level default encoding. `writeString`, `writeBVarchar`, `writeUsVarchar`, `writeUsVarbyte` and `writePLPBody` take the encoding explicitly. Only two places in `src` relied on the default; the test call sites now pass what their constructor used to. - `copyFrom` is replaced by `writeBuffer`; the `buffer`, `position`, `compositeBuffer`, `makeRoomFor` and `newBuffer` internals are gone. - The ALL_HEADERS writer patched the total length in place through `data`, relying on it being a live view of the composite. It now writes the (constant) length up front, per MS-TDS s2.2.5.3. Adds `benchmarks/tracking-buffer/writable-tracking-buffer.js`. Against tedious 20.0.0, the buffer itself is 2-3x faster for payload-shaped writes, and RPC request serialization (which builds many small tracking buffers) goes from ~12.8k to ~17k requests/s for 20 scalar parameters and from ~10.4k to ~17.8k for 20 date/time/bigint/guid parameters. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
The comment claimed the returned array is not modified by later appends, which is not the case: it is the list's own array, and a later seal or a pass-through append pushes onto it. Only `consume` replaces it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
`writePLPBody` and `writeMoney` had no callers: PLP framing lives in the types, and `money.ts` has its own serializer. `writeUsVarbyte` only ever receives a `Buffer`, so it no longer takes an encoding. The merged buffer list tests now live in the same `describe` block as the rest of the class's tests, with the same naming. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
Nothing calls slice with a range; the parameters only mirrored bl's signature. slice() now copies all appended and not yet consumed bytes, as the always-copy counterpart of `data`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
`append` accepted a buffer, a string with an encoding, or a list of buffers, duplicating `writeBuffer` and `writeString` with a signature whose encoding only applied to one branch, and putting type checks on the hot path. `writeBuffer` now owns the pass-through of large buffers and `writeString` the inline UCS-2 encoding; the other string writers delegate to them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
- Assert that `data` is a view of a single chunk and a copy of several. - Assert that buffers just below the chunk size are copied, and bound the chunk count in the coalescing test so that sealing on every write fails. - Cover the inline / native string encoding threshold and surrogates. - Make the chunk boundary test actually straddle the first chunk; the previous 1023-byte write got a chunk of exactly its size instead. - Read `data` once in the token stream parser test instead of relying on two reads returning the same view. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
The hand-written UCS-2 encoder for short strings is not worth carrying. Writing in place with the three-argument `buffer.write` keeps the request-path numbers; only the class micro-benchmark gives some back. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
60d02ca to
0979d93
Compare
|
CI status on 0979d93 (the rebase onto current master; the branch had been created from a stale local
I'll re-run the failed jobs once when the run completes. Locally on this commit the full integration suite passes against SQL Server 2022 (except the environment-only dangling-sockets timeout test), and lint, CodeQL and the passing Azure jobs are green. Generated by Claude Code |
|
Reviewed the diff ( Correctness
Minor observation (not a bug)The Test coverageVery thorough for the core class: chunk coalescing, chunk-boundary writes, large-buffer referencing vs. copying, PerformanceNumbers in the PR description are consistent with the design (removing per-growth SecurityNo concerns — this is pure binary wire-protocol serialization with no untrusted-input parsing changes, and the Code qualityThe rewrite is a nice simplification: dropping constructor-time size/encoding hints (which the growth model makes unnecessary) and dead code ( Nice work overall — didn't find anything blocking. |
Whether a copied `data` buffer shares an `ArrayBuffer` with a chunk depends on Node's allocation pool (it did on Windows with Node 26). Assert the copy semantics behaviourally instead: modifying the returned buffer does not affect the list. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
Review:
|
Replaces the exported constant, and with it the one-off `module.exports.CHUNK_SIZE` re-export after the CommonJS override. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
ReviewReviewed by walking the full diff and the class implementation by hand ( Overall: this is a careful, well-scoped rewrite. The chunk/seal/ensure state machine is correct as far as I can trace it, the migration of call sites is complete (no leftover references to Things I specifically checked and found sound
Minor nits (non-blocking)
Test coverageStrong — the new tests cover view-vs-copy semantics of No security or performance concerns beyond what's already covered above — the benchmark numbers in the description are consistent with what I'd expect from eliminating concatenation-on-growth. |
…uffer test Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug
ReviewReviewed the What I checked specifically
Minor, non-blocking observations
Test coverageThe added/rewritten unit tests in Performance / securityNo concerns. The chunk-doubling growth strategy is the standard amortized-O(1) approach and matches the benchmark claims structurally. No user-controlled input influences allocation sizes in a way that differs from before, and as noted above, no uninitialized-memory exposure is possible given how Nice work — this is a meaningful perf win with a notably careful validation story for a low-level buffer rewrite. |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #1773 +/- ##
==========================================
+ Coverage 81.02% 81.64% +0.62%
==========================================
Files 92 92
Lines 4948 4931 -17
Branches 938 932 -6
==========================================
+ Hits 4009 4026 +17
+ Misses 640 609 -31
+ Partials 299 296 -3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
On 6306338, SQL Server 2016 Windows / Node.js 26.x failed in the "install SQL Server" step: the SQL Server 2016 Generated by Claude Code |
|
Run 33640561947 on 6306338 finished: 23 of 24 jobs passed; the only failure is the SQL Server 2016 / Node.js 26.x installer step described above. I tried to re-run the failed job and the API refused ( Generated by Claude Code |
|
🎉 This PR is included in version 20.2.1 🎉 The release is available on: Your semantic-release bot 📦🚀 |
Problem
WritableTrackingBuffergrows by concatenating everything written so far into a composite buffer on every growth step, anddatareturns that composite. Every growth is a full copy of the data written so far,Buffer.alloczero-fills each new chunk, and callers work around it by computing exact sizes up front (e.g. the RPC payload computes the byte length of each parameter name purely to size a buffer for it).writeBufferalways copies, so a large value written through it costs its full size again.This is the first of a series of changes to the parameter serialization path (a resolve/write type contract and streaming table-valued parameters follow), all of which write into a shared buffer and depend on it not copying on growth.
Change
WritableTrackingBufferis now a write-side buffer list:WritableTrackingBuffer.CHUNK_SIZE). The first chunk is 64 bytes and each sealed chunk is followed by one twice its size, up to the cap; a single write larger than the open chunk gets a chunk of exactly its size. When a write does not fit, the open chunk is sealed into a list and a new one started. There is no concatenation on growth.writeBufferare referenced rather than copied, so large values cost no extra memory. (Such buffers must not be modified until consumed.)buffer.write, as before; there is no intermediateBuffer.from.bl'sBufferList:length,getBuffers,consume,slice.datastays for the existing callers and is a view rather than a copy when everything fits into a single chunk. The returned buffer must not be modified; the one caller that did (see ALL_HEADERS below) is rewritten.Cleanups that go with the new storage model:
writeString,writeBVarcharandwriteUsVarchartake the encoding explicitly. Three call sites insrcrelied on the default, allucs2: the RPC procedure name, the RPC parameter names, and the TVP TYPE_INFO. The constructor encodings in the transaction payloads (threeascii, oneucs2) were dead, since every string written there passesucs2explicitly. The test call sites now pass what their constructor used to.writePLPBodyandwriteMoneyhad no callers (PLP framing lives in the types,money.tshas its own serializer).writeUsVarbytehas no callers insrcand its only test callers pass aBuffer, so its string branch and encoding parameter are gone.copyFromis replaced bywriteBuffer.buffer,position,compositeBuffer,makeRoomForandnewBufferinternals are gone. Nothing outside the class used them (the NTLM payload resetpositionto zero immediately after construction, a no-op).writeToTrackingBufferpatched the TotalLength in place throughdata, relying on it being a live view of the composite. It also wrote the length of everything in the buffer, which only equalled the headers' length because the buffer happened to be fresh. It now writes the constant length up front: 4 (TotalLength) + 18 (the transaction descriptor header), per [MS-TDS] s2.2.5.3.The class is not exported from
src/tedious.ts. It is reachable by deep-importingtedious/lib/tracking-buffer/writable-tracking-buffer, sincepackage.jsonhas noexportsmap; anyone doing that sees the constructor and method signature changes.Measurements
Against tedious 20.0.0 on the same machine. New benchmark
benchmarks/tracking-buffer/writable-tracking-buffer.js(aWritableTrackingBufferbuilt frompiecesgroups of small fixed-width writes, a short UCS-2 string and a 16 byte buffer, then read throughdata; it exercises the coalescing path, not pass-through):RPC request serialization (consuming
RpcRequestPayloadfor one request; the payload builds a small tracking buffer per parameter and, for several types, per value):benchmarks/request/rpcrequest-payload-varbinary.js n=200)The second table is why the first chunk starts at 64 bytes and
datareturns a view for single-chunk contents: with a 1 KB first chunk and a copyingdata, the many tiny per-value buffers made request serialization ~15% slower than 20.0.0 despite the class being 2-3x faster in isolation.End to end (bulk load, TVP call, varbinary insert against SQL Server 2022) is unchanged within noise; the server round-trip dominates there.
Validation
Buffer.fromincluding surrogates and strings larger than the open chunk,databeing a view of a single chunk and a copy of several, fixed-width writes across chunk boundaries,consumeandslice).should not leave any dangling sockets after connection timeout).🤖 Generated with Claude Code
https://claude.ai/code/session_01Fxv5h4UMCGJEpjgCKcAxug