Repository navigation
feat: add a pull-based API for consuming responses - #1797
Draft
arthurschreiber wants to merge 21 commits into
Draft
arthurschreiber wants to merge 21 commits into
arthurschreiber wants to merge 21 commits into
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.
When `parser.streamValues` is set, rows are no longer delivered as single
`ROW`/`NBCROW` tokens holding every value in full. Instead, each row is
delivered as a sequence of tokens:
RowStartToken
for each column, either:
ColumnValueToken (a value read as a whole)
ValueStartToken, ValueChunkToken*, ValueEndToken (a streamed PLP value)
RowEndToken
Value chunks reference the incoming data without copying it, so values of
any size can be consumed without holding them in memory. As tokens are
parsed lazily, `streamValues` can be switched at any time (e.g. after
looking at a result set's `COLMETADATA`) and applies to the following rows.
`PLPReader` now exposes `readLength` and `readChunk`, which are shared by
the buffered and the streaming paths. Token readers can return a sequence
of tokens by setting `hasMore`. `TokenHandler` gains default handlers for
the new tokens, so a request level streaming API can be built on top.
Return values are still read in full.
A request created without a completion callback is consumed by pulling
its response, instead of via events:
const request = new Request('SELECT id, content FROM documents');
connection.execSql(request);
for await (const row of request.rows()) {
await pipeline(row.stream('content'), createWriteStream(`${row.get('id')}`));
}
- `rows()` iterates the rows of a single result set, `results()` iterates
multiple result sets. The loop ending means the request completed, and
errors of the request are thrown by the loop. Stopping a loop early
cancels the request, and waits for the cancellation to complete.
- Values are read in order. Values before a row's first `max` value are
available synchronously via `get()`. `max` values are streamed via
`stream()`, or read in full via `await read()`, which skip any values in
between.
- `outputParameters()` reads the return status and output parameters,
streaming `max` ones the same way. The server returns output parameters
of `max` types after all others, so parameters are located by name.
Output parameters nobody asked for are discarded before the next request.
- `finish()` reads the rest of a response and returns the row count,
return status and (buffered) output parameters.
The response is read by a `ResponseReader`, which passes all tokens the
consumer does not pull to the request's token handler, so errors, row
counts and transaction state are tracked just like for event consumers.
Operations run synchronously while the data they need is buffered, and
only return a promise once they need to wait for more data.
In streamed mode, the parser now returns rows without (non-`null`) PLP
values as single `ROW`/`NBCROW` tokens, and rows with PLP values as a
`RowStartToken` holding the values before the first PLP value. Return
values with PLP values are streamed as well.
The private `rows` property of `Request` and `BulkLoad` (rows collected
for `rowCollectionOnRequestCompletion`) is renamed to `collectedRows`, as
it clashes with the new `rows()` method.
`Row.values()` only works for rows that were read completely, which is not the case for rows with `max` values, as those are read on demand. `readValues()` reads all remaining values of a row in full (including `max` values) and returns all of the row's values.
…t using`
A request without a completion callback is now in progress until it is
finished, and the connection refuses further requests until then:
{
await using request = new Request('SELECT id FROM users');
connection.execSql(request);
for await (const row of request.rows()) {
// ...
}
} // the request is finished here
`Request` implements `Symbol.asyncDispose`, which calls `finish()`.
Making a request while a pulled one is unfinished fails with an
`EINVALIDSTATE` error pointing to `finish()` and `await using`. This
replaces discarding a response's unread output parameters when the next
request is made.
`finish()` can be called any number of times and raises an error at most
once. It only raises the request's error if the response was not read via
`rows()`, `results()` or `outputParameters()`, which raise it themselves,
so disposing a request never raises an error a second time.
Executing a request without a completion callback now returns the
`Response` of that execution, which is what gets pulled and finished:
const request = new Request('SELECT id, name FROM users');
await using response = connection.execSql(request);
for await (const row of response.rows()) {
// ...
}
A `Request` goes back to being a reusable description of a statement.
`rows()`, `results()`, `outputParameters()`, `finish()` and
`Symbol.asyncDispose` move from `Request` to `Response`, and every
execution gets its own response. For requests with a completion callback,
the executors return a response too, but it can not be pulled.
`connection.prepare()` now returns a promise of a `PreparedStatement`,
which is executed via `statement.execute(parameters)` and unprepared via
`statement.unprepare()` - or `await using`, which disposes of the
responses of its executions first:
await using statement = await connection.prepare(request);
for (const id of ids) {
await using response = statement.execute({ id });
// ...
}
This fixes `unprepare()` of a pulled request being treated as an
unfinished pulled request, which blocked the connection for good.
`sp_prepare` and `sp_unprepare` responses are now always consumed
internally.
Also reset `Request.canceled` once an execution completed. A canceled
execution of a prepared request used to fail all later executions, and
its unpreparing, with `ECANCEL`.
`execSql`, `execSqlBatch`, `callProcedure`, `execute` and `prepare` return a `Response` (or a `PreparedStatement`) only for requests without a completion callback. For requests with a callback, they return nothing, as before this branch. To tell the two apart at the type level, the `Request` constructor is typed with overloads: `new Request(sql)` creates a `PulledRequest`, and `new Request(sql, callback)` a `CallbackRequest`. The executors are overloaded on those, so `connection.execSql(new Request(sql))` is typed as returning a `Response`, and `connection.execSql(new Request(sql, cb))` as returning `void`. A value typed as plain `Request` gets `Response | void`. (Making `Request` generic over its callback does not work for this: TypeScript infers the type parameter of an inline `new Request(...)` from the overload being tried, not from the callback.) `prepare()` of a request with a callback no longer returns a promise, which also removes the need to mark that promise's rejection as handled.
`connection.prepare()` attached a `returnValue` listener to the request to pick up the prepared statement's handle from `sp_prepare`, and never removed it. Besides adding another listener on every `prepare()`, the listener treated the output parameters of every later execution as unexpected output parameters of `sp_prepare`, and failed the execution with "Unexpected output parameter ... from sp_prepare" - so prepared statements with output parameters did not work at all. The `RequestTokenHandler` now stores the handle while the request is being prepared, so no listener is needed. The `returnValue` event for the handle is still emitted, as before.
`stream()` used to return a `Readable` synchronously, which ended without
any data for a `null` value - indistinguishable from an empty value.
Whether a value is `null` is only known once the value is reached, which
for values after an unread one requires reading ahead, so `stream()` now
returns a promise: of `null` for a `null` value, or of a `Readable` of the
value's data otherwise.
const content = await row.stream('content');
if (content !== null) {
await pipeline(content, destination);
}
This lines up `stream()` with `read()`: both may read ahead (and skip
unread values) to reach a value, while `get()` returns an already read
value synchronously.
`stream()` of a value that is not of a `max` type now throws, pointing to
`get()`, instead of returning a stream that ends right away.
…lready `finish()` (and so disposing a response via `await using`) did not raise the request's error if the response had been pulled via `rows()`, `results()` or `outputParameters()`, assuming the consumer had received the error from there. A response whose `rows()` was never iterated thus lost its error. The response now remembers which errors were actually raised to the consumer - by a loop, `read()`, `stream()`, `readValues()` or `outputParameters()` - and `finish()` raises the request's error unless it is one of them. The error of a cancellation the consumer caused by stopping a loop early is not raised either. This plays well with `await using`: a scope left normally raises the error from disposal; a scope left via an exception while the request's error was not delivered raises a `SuppressedError` carrying both, and an error that the loop already raised is never raised twice.
`stream()` now resolves to an `AsyncIterable<Buffer>` of the value's
chunks (or `null`), instead of a `Readable`:
const content = await row.stream('content');
if (content !== null) {
for await (const chunk of content) {
// ...
}
}
The iterator pulls each chunk from the response on demand, resolving
right away when the chunk was received already, which replaces pumping
chunks into a `Readable` in the background - and with it, destroying the
stream and waiting for its pump when a value is skipped. Stopping the
iteration early just stops it: the rest of the value is skipped once the
next value or row is read, like any other unread value. An iteration of a
value that was skipped fails.
Async iterables work with `pipeline()` and `Readable.from()` directly,
and mirror streamed `max` parameters, which are given as async iterables
as well.
Breaking out of `rows()` or `results()` no longer cancels the request. The remaining result sets are skipped so the request runs to completion, output parameters stay readable, and any error of the request is raised by `finish()`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HGJsu1u44CHnt4nkkQ5ias
…n finish Executors, `prepare()` and `PreparedStatement.execute()` accept an `ExecutionOptions` object with a `signal`. Aborting it cancels the execution, which then fails with the signal's reason. Disposing an aborted response does not raise that reason again, while `finish()` still does. Stopping a `rows()` or `results()` loop early no longer drains the rest of the response right away. `finish()` reads it, so an abort in between avoids the drain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HGJsu1u44CHnt4nkkQ5ias
Rows read via the pull API used to be arrays of `{ value, metadata }`
objects, like the rows of the `row` event, although the metadata is the
same for every row (it is the result set's `columns`).
In streaming mode, the parser now reads the values of a row into a plain
array: a row without streamed values becomes a single `RowValuesToken`,
and `RowStartToken` and `ColumnValueToken` carry values only. `Row` and
`OutputParameters` keep plain arrays of their values, and look up the
metadata of a value by its position. `Row#values()` returns the row's
array instead of copying it.
Parsing a streamed row (int, nvarchar, datetime2, decimal, varchar and
bit columns) takes ~5% less time, with ~15% fewer minor GCs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGJsu1u44CHnt4nkkQ5ias
`Response#rowBatches(size?)` and `ResultSet#batches(size?)` iterate rows in batches, as the "easy" way to consume a response: all values of the rows are read in full (including values of `max` types, which are buffered), so all rows of a batch stay valid. A batch has `size` rows (only the last batch of a result set can have fewer). Without a `size`, a batch has all rows that can be read from the data received so far, but at least one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HGJsu1u44CHnt4nkkQ5ias
This reverts commit b81b743. Reading rows in batches made no measurable difference in performance compared to `rows()`, so the extra API is not worth keeping. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HGJsu1u44CHnt4nkkQ5ias
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
`max` types, and with them streamed values, only exist from TDS 7.2 on. 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 #1797 +/- ##
==========================================
+ Coverage 84.38% 84.60% +0.21%
==========================================
Files 95 96 +1
Lines 4702 5462 +760
Branches 880 1053 +173
==========================================
+ Hits 3968 4621 +653
- Misses 473 534 +61
- Partials 261 307 +46 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
`results()` now returns, in order, a `Result` for each result set, and for each statement without a result set that reports a row count or an error (e.g. an `INSERT`, `UPDATE` or `DELETE`). `DONE` tokens that report nothing (e.g. of `DECLARE`, `PRINT` or `DROP` statements, or of all statements with `SET NOCOUNT ON`) are skipped, so statements executed via `sp_executesql` return the same results as a batch. A `Result` is an async iterator of its rows - there are none if it has no result set. `rows()` skips results without a result set. Each result has the info messages and errors of its statement (and the messages of statements before it without a result of their own). All info messages are also collected in `Response#messages`. Errors are still raised when the response ends. `finish()` returns the row count, messages and errors of each result instead of the output parameters, which are read via `outputParameters()` - so `finish()` no longer buffers values of `max` types that nobody asked for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HGJsu1u44CHnt4nkkQ5ias
…uard re-executions - A read that waits for more data now fails as soon as the request completes with an error (e.g. the connection was closed or lost), instead of waiting forever for data that never arrives. `finish()` and `await using` no longer hang in that case either. - Operations on a response (`next()`, `read()`, `stream()`, `readValues()`, `outputParameters()`, `finish()`) now run one after the other, in the order they were called. Calling `next()` again before the previous call settled returned rows out of order, or ended the iteration early. Draining a canceled response also waits for the consumer's pending read now. Operations on data that was received already stay synchronous. - Executing a request without a callback while its previous execution (or its preparation) is still in progress now fails that execution with `EINVALIDSTATE`, without touching the one in progress. Previously, the earlier response never completed, and the connection could not be used anymore. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FFfrSh4ZKQbv3zDLhrTZTC
The server ends the response of a canceled batch with a `DONE` token that has the error flag set, but without sending an error first. `results()` returned an empty result for it before raising the cancellation. The error flag now only makes a result if an error was read for the statement. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FFfrSh4ZKQbv3zDLhrTZTC
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This adds a second way to consume the response of a request: pulling results, rows, values and output parameters with
for awaitand promises, instead of listening to events. It sits alongside the existing API. A request with a completion callback works exactly as before, and a request without one returns aResponseto pull from.API
Response. This coversexecSql,execSqlBatch,callProcedureandexecute.preparereturns aPromise<PreparedStatement>. With a callback, all of these returnvoidas before; the TypeScript overloads use thePulledRequestandCallbackRequesttypes.Responseresults()iterates the results in order. There is oneResultfor each result set, and one for each statement without a result set that reports a row count or an error (e.g.INSERT,UPDATE,DELETE). AResultis an async iterable of its rows; there are none if it has no result set.rows()iterates the rows of the single result set of a request. Results without a result set are skipped, and it throws if there is more than one result set.messagesholds all info messages read so far (PRINT,RAISERRORwith a low severity, warnings).outputParameters()resolves to the output parameters and the return status once the results were read.finish()reads the rest of the response and resolves to aRequestSummary:rowCount(total),returnStatus,results(the row count, messages and errors of each result) andmessages. Output parameters are only available viaoutputParameters(), sofinish()does not buffermaxvalues nobody asked for.Resultcolumnsis empty if the result has no result set.rowCountisundefinedif the server did not report it (withSET NOCOUNT ON).messagesanderrorshold those of the result's statement.messagesalso includes the messages of statements before it that have no result of their own, likePRINT.Rowget(key)returns a value that was already read, by column name or index.read(key)reads a value in full;stream(key)returns the chunks of amaxvalue as an async iterable, ornullfor anullvalue.length(key),values()andreadValues()round this out.OutputParametershave the sameget,read,streamandlength, keyed by parameter name, plusreturnStatus.PreparedStatementhasexecute(parameters, options)andunprepare(), and can be disposed withawait using.ExecutionOptionstake{ signal }on every execution. Aborting the signal cancels the execution, which then fails with the signal's reason. This part is also proposed on its own in feat: experimental support for abort signals when executing requests and bulk loads #1798.Which statements produce a result
SQL Server does not send a token for every statement.
DECLAREsends nothing,IF … PRINTsends twoDONEtokens, and withSET NOCOUNT ONstatements insidesp_executesqlsend nothing at all. A result is therefore a result set, or aDONE/DONEINPROCtoken that reports a row count or an error. OtherDONEtokens are skipped, and so is theDONEPROCthat endssp_executesqlor a procedure. As a result:execSql(sp_executesql) return the same results.SET NOCOUNT ON,results()returns exactly the result sets.doneevents of the event API.Behavior
finish()orawait using. Disposal reads the rest of the response and raises errors the consumer has not seen yet.maxvalues are streamed piece by piece, straight from the incoming data without copying, and never buffered unless read withread()orreadValues(). Values that are not read are skipped.break) skips the remaining rows. The rest of the response is drained byfinish(), so the execution can still be canceled until then.Changes to the existing API
new Request(sql)accepts no callback; that makes it a pulled request.execSql,execSqlBatch,callProcedure,executeandprepareaccept an optionalExecutionOptionsargument (see above).rowsfield ofRequestandBulkLoadis renamed tocollectedRows.prepareof a request without a callback only emitserrorif someone listens for it; otherwise the error rejects the promise thatconnection.prepare()returns.Implementation
maxvalues (streamValues). With it enabled, it returns rows as arrays of their values, split into several tokens around each streamed value.src/pull-response.tsreads the response's tokens on demand. Tokens that the consumer does not pull (errors, info messages, environment changes,DONErow counts) still go to the request's token handler. The handler keeps the connection's state (transactions, row counts, errors) in sync exactly as for event-based requests, and still fires the connection'sinfoMessageanderrorMessageevents.Status
This is a draft to review the API as a whole. It is too large to merge as one PR, and would be split up for merging. Not covered yet: documentation beyond the doc comments, and bulk loads, which keep their existing API.
Testing
test/integration/pull-api-test.tshas 65 tests, run against SQL Server 2022 with TDS 7.1 (the tests that usemaxtypes are skipped there), 7.2 and 7.4. The tests forresults()cover:sp_executesqlreturning the same results;SET NOCOUNT ON;rows()next to results without a result set.should not leave any dangling sockets after connection timeout, which fails the same way on master in my environment (the connection is refused instead of timing out).🤖 Generated with Claude Code
https://claude.ai/code/session_01HGJsu1u44CHnt4nkkQ5ias