Repository navigation
perf: convert the token parser into a push parser - #1794
Conversation
The token parser used to pull data from the message via an async iterator, turning every token into promises and generator yields, and wrapping the result in a `Readable` stream for backpressure. Data is now pushed into the parser via `write(chunk)`, and tokens are pulled out synchronously via `read()`, which returns `undefined` once more data is needed. Tokens are still parsed lazily, one at a time, so effects of handling a token (like the TDS version negotiated by `LOGINACK`) apply to all tokens that follow it. Short tokens are simply re-parsed from their start once enough data has arrived. The parser tracks how many bytes are needed and buffers incoming chunks until then, so a large token spanning many chunks is not re-parsed (and re-copied) for every chunk. Tokens that can grow arbitrarily large (`COLMETADATA`, `ROW`, `NBCROW` and `RETURNVALUE`) are parsed incrementally by `TokenReader`s, which keep their progress column by column across chunks. PLP values are read by a `PLPReader`, which consumes whatever part of the value is available without waiting for complete PLP chunks. Reading a large PLP chunk is now linear instead of quadratic in its size. `token-stream-parser` drives the push parser from the message and dispatches tokens to the handler, pausing between tokens when requested. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HGJsu1u44CHnt4nkkQ5ias
The benchmark passed an empty object as the parser's `debug` argument, so it crashed with `debug.token is not a function` on the first token. Pass a no-op `token` function, like the other token parser benchmarks do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HGJsu1u44CHnt4nkkQ5ias
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #1794 +/- ##
==========================================
+ Coverage 82.50% 84.38% +1.88%
==========================================
Files 95 95
Lines 4863 4701 -162
Branches 914 880 -34
==========================================
- Hits 4012 3967 -45
+ Misses 575 473 -102
+ Partials 276 261 -15 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The parser emitted `end` and `error` from within a promise's `then` callbacks. An exception thrown by a listener - e.g. by a request's completion callback, which runs from the `end` listener - thus became an unhandled promise rejection, instead of an uncaught exception like before. Applications that only log unhandled rejections would silently swallow such errors. The events are now emitted via `process.nextTick`, outside of the promise chain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HGJsu1u44CHnt4nkkQ5ias
|
Follow-up (not part of this PR): cheaper handling of tokens that cross a chunk boundary When a token straddles two incoming chunks, the push parser's Proposed approach: "stitch" instead of concat
Alternatives considered
Microbenchmark: 20,000 rows of two
Prototype in the real parser: stitching gave about +13% on small-row results and was mostly within noise elsewhere. It produced identical tokens to master's parser under about 29 different chunkings of 10 recorded SQL Server 2022 responses. Pairing it with throwing Other measured follow-ups, also deferred:
Generated by Claude Code |
|
🎉 This PR is included in version 20.3.3 🎉 The release is available on: Your semantic-release bot 📦🚀 |
Master now contains the push parser (#1794) that this branch was built on, and the fixes for output parameters of prepared statements (#1789) and for executing a request again after it was canceled (#1790), which this branch had earlier versions of. The branch's versions of the parser files are kept (they extend the merged parser), with the `process.nextTick` fix from #1794. `Request.callback` only resets `canceled` before calling back, like on master: resetting it afterwards would also clear the cancellation of an execution that was started from the callback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HGJsu1u44CHnt4nkkQ5ias
The token parser used to pull data from the message through an async iterator. That turned every token into promises and generator yields, and wrapped the output in a
Readablestream for backpressure.This PR turns it into a push parser: data is written in, and tokens are read out synchronously. The public API doesn't change.
Changes
src/token/stream-parser.ts):write(chunk)adds data.read()returns the next token, orundefinedwhen it needs more data.end()throws if the data stopped partway through a token.LOGINACKsets the TDS version.COLMETADATA,ROW,NBCROWandRETURNVALUE) are parsed incrementally byTokenReaders. These keep their progress, column by column, across chunks.max(PLP) values are read by aPLPReader, which consumes whatever part of the value is available instead of waiting for whole PLP chunks. Reading a large PLP chunk now takes time proportional to its size, where it used to grow quadratically.token-stream-parser.tsnow feeds the message into the push parser and hands tokens to the handler, pausing between tokens when asked to.endanderrorviaprocess.nextTick, outside the promise that drives parsing.metadataParsefrommetadata-parser.ts.benchmarks/token-parser/simple-tokens.jscrashed on master withdebug.token is not a function. It passed{}as the parser's debug logger; it now passes a no-optoken()stub, like the other token-parser benchmarks.Behaviour change
All tokens from one packet are now handled in one go. Promise callbacks that token event listeners (such as
row) queue therefore run after the whole packet, instead of between tokens. Synchronouspause()andcancel()calls in a listener still take effect before the next token.Performance
Both builds (
masterate3f43f7and this branch) ran on the same machine against SQL Server 2022 in Docker. Each configuration ran twice and the better run is shown; higher is better.Token parser microbenchmarks (
benchmarks/token-parser/, iterations per second):simple-tokensn=100simple-tokensn=1000done-tokentokenCount=10 n=1000done-tokentokenCount=1000 n=1000done-tokentokenCount=10000 n=1000colmetadata-tokentokenCount=10 n=1000colmetadata-tokentokenCount=1000 n=1000colmetadata-tokentokenCount=10000 n=1000Queries against SQL Server (
benchmarks/query/, queries per second):select-many-rowssize=100 n=1000select-many-rowssize=1000 n=1000select-many-rowssize=10000 n=1000select-nvarcharsize=10 n=1000select-nvarcharsize=10000 n=1000select-varbinarysize=100 n=100select-varbinarysize=1048576 n=100select-varbinarysize=10485760 n=100Across the full result matrix, the token-parser benchmarks range from 0.98× to 4.66×. The query benchmarks range from 0.93× to 1.58×. The largest gains are on result sets with many rows. Single large
varbinary(max)values stay within run-to-run noise (0.93–1.15×), where the network transfer dominates.Tests
test/unit/token/stream-parser-test.tscovers:read()returningundefineduntil a token is complete;end()throwing on data that stops partway through a token or a partly parsed row;ROWandRETURNVALUEtokens with PLP values, andNBCROWtokens, arriving one byte at a time;nullvalues;test/unit/token/token-stream-parser-test.tschecks that exceptions thrown byendanderrorlisteners surface as uncaught exceptions.NBCROW, 20k-row results, a 5.9 MBmaxvalue, output parameters, andPRINT/RAISERROR.Checks
npm run lint(eslint andtsc)npm run buildInitiate Connect Test, which also fails on master in the local test environment.This is the first of a series of PRs toward a pull-based API for consuming responses, including streaming large values. It stands on its own, without any of them.
🤖 Generated with Claude Code
https://claude.ai/code/session_01HGJsu1u44CHnt4nkkQ5ias