Skip to content

feat(qwp): add error positions, timings, row counts and paging to queries - #81

Open
glasstiger wants to merge 20 commits into
mainfrom
ia_query_info
Open

glasstiger wants to merge 20 commits into
mainfrom
ia_query_info

Conversation

@glasstiger

@glasstiger glasstiger commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds the client side of questdb/questdb#8356, which lets a QWP query report what /exec reports besides the rows: where an error points in the SQL text, the query's timings and JIT use, and the size of a result read one page at a time. The server advertises the feature with a new SERVER_INFO capability, QUERY_DETAILS, and each query opts in. The new bits leave capability 0x08 and flag 0x02 to per-query timeouts, which the released C and Python clients already use. A query without the new options sends the same request and reads the same responses as before. The Node.js and browser clients both get it: the code is in the shared egress session.

const page = await session.query(sql, {
  skipRows: 200,
  maxRows: 100,
  countRows: true,
  stats: true,
  errorPosition: true,
});
for await (const batch of page) render(batch);
const completion = await page.completion; // completion.stats, completion.rowCount
// A failed query rejects with a QwpEgressQueryError whose position indexes `sql`.

Changes

  • query() and queryViews() take errorPosition, stats, countRows, skipRows and maxRows.
    • QwpEgressQueryError.position is the UTF-16 offset into the SQL string, which is a JavaScript string index, and the message is then bare, without QuestDB's [n] prefix.
    • Completions carry stats (QwpQueryStats: compile, execute, count and client-wait nanoseconds, and jitCompiled). A result's completion also carries rowCount, the size of the whole result whatever the window left out.
  • On an endpoint without QUERY_DETAILS the reports are dropped, as resetDictionary is without QUERY_FLAGS, so they are safe during a rolling upgrade. A row window is not dropped, because such a server would stream every row:
    • the query rejects with the new QwpEgressRowWindowUnsupportedError before anything is sent, and so does a statement, which the client cannot tell from a query before sending it;
    • a query that failover would replay on such an endpoint fails with it instead of being replayed.
  • Low-level codec: encodeQwpQueryRequest() takes the new QWP_QUERY_FLAG_* bits and the skipRows and maxRows values two of them carry, and requires each value exactly when its flag is set. The new decodeQwpEgressMessageWithQueryFlags(bytes, queryFlags) reads the trailers the flags add to RESULT_END, EXEC_DONE and QUERY_ERROR. decodeQwpEgressMessage(bytes) keeps its single parameter, so frames.map(decodeQwpEgressMessage) still works: map() passes each index as a second argument, which a queryFlags parameter would read as flags.
  • The reconnecting egress transport reads only the kind and request ID of each frame. It cannot know the request's flags, so a RESULT_END with trailers looked malformed to it, and the query would have been replayed until the reconnect budget ran out. The session decodes and validates the trailers.
  • A protocol error the session finds after the socket has dropped no longer fails the whole session. A full batch pool can hold up the check of a terminal response until the connection is gone: a RESULT_END whose totals do not match its batches, or, now that the transport leaves trailers to the session, one with trailers the request did not ask for. Such a rejection used to fail the session with QwpSendClosedError ("QWP WebSocket is not open"). It now goes to the reconnect already under way, which replays the query as it does after a protocol error found while connected, and it is charged to the same failoverMaxAttempts and failoverMaxDurationMs budget. This applies to every query, with or without the new options.
  • Documents the options in QWP.md and the READMEs, and regenerates the API reference. Most generated-file changes are TypeDoc source-link refreshes.

Public API

Additions only:

  • QwpEgressQueryOptions: errorPosition, stats, countRows, skipRows, maxRows.
  • QwpEgressQueryError.position, QwpResultEndMessage.rowCount and .stats, QwpExecDoneMessage.stats, QwpQueryErrorMessage.position, QwpQueryRequest.skipRows and .maxRows.
  • QwpEgressRowWindowUnsupportedError, QwpQueryStats, QWP_EGRESS_CAPABILITY.QUERY_DETAILS, and QWP_QUERY_FLAG_ERROR_POSITION, _STATS, _COUNT_ROWS, _SKIP_ROWS and _MAX_ROWS.
  • decodeQwpEgressMessageWithQueryFlags(), which decodes the responses to a request sent with query flags.

encodeQwpQueryRequest() now rejects queryFlags that set the skip or max bit without the matching value. Those bits had no meaning before, and a server that knows them would read the end of the request as the value. It also rejects a queryFlags that is neither a number nor a bigint, such as a string, which 5.0.0 coerced.

Validation

New codec, session and failover tests cover byte-level request and trailer encoding and decoding, capability gating, the bits left to per-query timeouts, the unsupported-window error, replays across endpoints with and without QUERY_DETAILS, trailers passing through the reconnecting transport, and a terminal response rejected after the socket has dropped.

  • Coverage: under V8 coverage, every changed production line and branch runs.

  • Mutation checks: each of these changes fails at least one test: reverting the transport change or the replay withdrawal, dropping an error position of zero, keeping a detailed query's flags for the next plain query, always withdrawing a row-window replay, clearing the flags before a queued response is decoded, dropping the request-ID range check, giving decodeQwpEgressMessage() back a second parameter, moving the query details back onto the per-query timeout bits, and, for a rejection that arrives during a reconnect, failing the session as before, skipping the recovery charge, keeping the stale messages or waiting for the reconnect.

  • Passed: 1,123 QWP and docs tests, 245 other unit tests, 39 distribution tests and 12 real-Chromium tests.

  • Passed: all typechecks, including typecheck:dist with TypeScript 4.9, ESLint, Prettier and check:packages.

  • Passed: an end-to-end run of the built Node.js package, with default options, against a server built from feat(qwp): add error positions, timings, row counts and paging to QWP queries questdb#8356. Error positions and messages match /exec, including for non-ASCII SQL. Pages and row counts are correct for table scans, ORDER BY ts DESC, filters and maxRows: 0, and statistics arrive on results and statements.

  • Not run: the Docker-based ILP integration tests, which this change does not touch.

Merge order

This branch is stacked on #80 and #79 (merged in), so their commits appear in this PR until they land on main.

QwpEgressQuery.cancel() and grantCredit(), and the same methods of the
queryViews() handles, return promises. Once the session had handled a
query's terminal response, they threw QwpProtocolError ("QWP response
references inactive request ID") synchronously instead, before any
promise existed, so a .catch() on the result never ran. A cancel() from
an AbortSignal listener or a timer that fired after the query completed
became an uncaught exception, which ends a Node process. A session that
had failed or was closing threw its failure the same way. Node and
browsers were both affected: the fault is in the shared egress session.

- cancel() on a query that has ended resolves without sending anything,
  even if its session has since failed or closed.
- grantCredit() on a query that has ended resolves without sending
  anything: it is owed no credit. The amount is checked first, whatever
  the query's state, and an invalid one rejects with a RangeError.
- The session's cancel(), abandon(), grantCredit() and rejectView()
  never throw. A request that has left the session is a no-op, and a
  failed or closed session rejects with its failure. requireActive()
  and its QwpProtocolError now serve only server messages, where an
  unknown request ID is a protocol fault.

Callers will notice that these methods no longer throw: each returns a
promise that resolves or rejects. grantCredit() also stops sending
CREDIT for a query whose consumer has retired while it still drains.
The methods return promises and none of the old behaviour was
documented, so this restores their contract: it is a bug fix, not a
breaking change.

Document the behaviour in the TSDoc and QWP.md, and regenerate the API
reference as CONTRIBUTING.md requires. Most generated-file changes are
TypeDoc source-link, line-number and reflection-ID refreshes.

Fixes #76
CONTRIBUTING.md counted any change to the behaviour of a published API,
its wire bytes or the errors it throws as breaking, "even when the new
behaviour is a fix". Ordinary bug fixes were marked breaking under it,
#78 among them, and each one called for a major release: data that fits
within max_buf_size no longer refused, or a promise-returning method
that threw synchronously and now rejects.

Define a breaking change by the API's contract instead -- what code
that relies on its documentation and types has to change -- in a
Breaking Changes section that the release and commit guidance both
point to. A fix that brings the implementation back to its contract is
a plain fix:, and its commit and pull request describe what callers
will notice. The wording of an error message is not part of the
contract.
…query alone

The test for control calls naming a request that has left the session
made them only once no query was active, so this.active was undefined
whether or not controlledQuery() compared request IDs. Dropping that
comparison passed every QWP test, although session.cancel(oldId) then
cancelled the query running now and session.abandon(oldId) retired it.

Start the next query before those calls, and check that it is still
running and that its QUERY_REQUEST is the only frame sent.
…ol calls

Mutating the late-control-call fix found regressions that every QWP test
still passed:

- The session's control calls for a request that has left it ran only
  while the next query was running, so controlledQuery() could throw, or
  read requestId off an undefined active query, on an idle session. They
  now also run on an idle session, and include rejectView().
- No test gave grantCredit() a bad amount on a running query, so the
  check could be confined to ended queries, left to the CREDIT encoder,
  or relaxed to accept numeric strings. A new test checks that each bad
  amount rejects with RangeError, never throws synchronously, sends no
  frame and leaves the query running.
- rejectView() on a closed or failed session, and the queryViews()
  controls once their session has closed, went untested.
A browser's WebSocket.close() accepts only 1000 and 3000-4999 and throws
InvalidAccessError for anything else. The client closes with 1001 in
QwpClient.close(), and with 1002 or 1011 on protocol errors, failed sends,
failed reconnects and a cancelled query that does not drain. In a browser
that close threw, the fallback found no terminate() to call, and the
connection settled as closed with a synthetic 1006 while the native socket
stayed open. Pooled query sockets outlived QwpClient.close(), and a
reconnect: false session whose cancel did not drain kept receiving the
cancelled result until the page unloaded.

closeSocket() now retries a close that a socket without terminate() refuses,
first with 1000 and the same reason, then with no code at all. A WHATWG
socket validates its arguments before it changes state, so the retry is
safe. ws accepts these codes and still sends them unchanged. A close that ws
refuses is still terminated at once, because ws enters CLOSING before it
validates and ignores any later close().

A browser still cannot drop a connection itself. Without flow control
QuestDB reads the close only once the query has ended, so in a browser the
query runs until then or until the browser abandons the closing handshake,
after 60 seconds in Chromium. QWP.md now says so.

Fixes #77
The comment said the close codes "still reach a Node server", but the
server is QuestDB; `ws` is the Node client. It now says that from Node
the codes still reach the server. Comment-only change.
The test that keeps a `ws` socket from being retried closed with 1000. That
skips the retry with 1000 which startClosingHandshake() gives a refused code
other than 1000, so moving the terminate() check after that retry still
passed all of test/qwp. With `ws` that change postpones terminate() by the
whole close deadline: `ws` enters CLOSING before it validates, and the
retried close() sends nothing. The test now also closes with 1011, and fails
on that variant. Test-only change.
…close

In a browser, a failed ingress negotiation waited for its close(1002) before
rethrowing. Since the refused 1002 is retried with 1000 and waits for the
server's reply, the connect deadline could fire first and turn the terminal
QwpDurableAckUnavailableError into a retryable upgrade timeout. Start the
close without waiting for it, as Node's upgrade-validation failures do.
…ries

QuestDB can now report over QWP egress what /exec reports besides the
rows (questdb/questdb#8356): where an error points in the SQL text, the
query's timings and JIT use, and the size of a result read one page at
a time. The server advertises this with a new SERVER_INFO capability,
QUERY_DETAILS, and each query opts in. Node.js and browsers both get it:
the code is in the shared egress session.

- query() and queryViews() take errorPosition, stats, countRows,
  skipRows and maxRows. QwpEgressQueryError carries position, the
  UTF-16 offset into the SQL string, and the bare message; completions
  carry stats (QwpQueryStats), and a result's completion carries
  rowCount, the size of the whole result whatever the window left out.
- On an endpoint without QUERY_DETAILS the reports are dropped, as
  resetDictionary is without QUERY_FLAGS, so they are safe during a
  rolling upgrade. A row window is not dropped, because such a server
  would stream the whole result: the query rejects with the new
  QwpEgressRowWindowUnsupportedError before anything is sent, and a
  query that failover would replay on such an endpoint fails with it
  instead of being replayed.
- encodeQwpQueryRequest() takes the new QWP_QUERY_FLAG_* bits and the
  skipRows and maxRows values two of them carry, and requires each value
  exactly when its flag is set. decodeQwpEgressMessage() takes the
  request's flags and reads the trailers they add to RESULT_END,
  EXEC_DONE and QUERY_ERROR.
- The reconnecting egress transport reads only the kind and request ID
  of each frame. It cannot know the request's flags, so a RESULT_END
  with trailers looked malformed to it, and the query would have been
  replayed until the reconnect budget ran out. The session decodes and
  validates the trailers.

A query without the new options sends the same request and reads the
same responses as before.

Document the options in QWP.md and the READMEs, and regenerate the API
reference as CONTRIBUTING.md requires. Most generated-file changes are
TypeDoc source-link, line-number and reflection-ID refreshes.
Every changed line of the query-details change already ran under the
suite, but six plausible regressions in it still passed. The new
assertions each fail one of them:

- An error position of zero, the start of the SQL text and what QuestDB
  reports for an error that names no position, is reported, both by the
  decoder and on QwpEgressQueryError. Other negative values than -1
  carry no position either.
- A plain query after a detailed one is sent without flags, and its
  baseline RESULT_END decodes without trailers.
- A row-window query that fails over to an endpoint with QUERY_DETAILS
  is replayed with the same window, flags and values alike, and the
  replacement's trailers decode. Only an endpoint without the capability
  makes it fail instead.
- A detailed RESULT_END still queued when the session closes is reported
  as the closed session, not as a malformed frame that closes the
  connection again with 1002.
- Values of neither numeric type are rejected as row-window values and
  as query flags, and the request ID keeps its uint64 bound.
The query-details change gave decodeQwpEgressMessage() an optional second
parameter, queryFlags. map() and Array.from() pass the element index there,
so frames.map(decodeQwpEgressMessage), which works with 5.0.0, read each
index as query flags: from index 4 on, a valid RESULT_END or EXEC_DONE
failed to decode as truncated, because the decoder looked for statistics
and row counts the frame does not carry. TypeScript accepts the call, even
under --strict.

decodeQwpEgressMessage() keeps the single parameter 5.0.0 published, and
still rejects a terminal frame that carries trailers. The new
decodeQwpEgressMessageWithQueryFlags(bytes, queryFlags) decodes the
responses to a request sent with query-details flags, and the query session
uses it. An options object as the second parameter would avoid the misread
too, but tsc rejects frames.map() with it, so existing callers would no
longer compile.

Document the new function in QWP.md, and regenerate the API reference as
CONTRIBUTING.md requires. Most generated-file changes are TypeDoc
source-link refreshes.
@glasstiger

Copy link
Copy Markdown
Collaborator Author

Review: approve

No Critical, Moderate or Minor issues found, and the test gate passes.

Scope: this PR's own commits, 6a3615b0..fca3f659 (4e8de12, 4f6fe54, fca3f65). The PR is stacked on #79 and #80. 6a3615b0 is main with both merged in: its source matches an automatic merge of their heads, and only generated docs differ. Their changes are left to those PRs. Generated docs/** was not reviewed as source.

Tests

Ten targeted one-line breaks of the production code. Nine fail at least one of the new tests:

  • the reconnecting transport's envelope decode reverted to the strict decoder
  • the replay withdrawal (withdrawUnreplayable()) removed
  • position >= 0 changed to > 0
  • the optional-trailer guard (reader.remaining > 0) dropped
  • stale flags kept after the transport re-encodes a request
  • detail flags sent without QUERY_DETAILS
  • the row-count and statistics trailers decoded in swapped order
  • the skipRows: 0 normalisation removed
  • responseQueryFlags never recorded

The tenth survives: recording replayedCredit before the encode in encodeActiveQueryRequest(). The order only matters if that encode throws, which is fatal either way, so no behaviour changes.

Checked at fca3f659

  • QWP and docs tests: 1,117/1,117 pass.

  • tsc passes for the root, browser and test configs. ESLint and Prettier are clean.

  • typecheck:dist passes, including TypeScript 4.9. check:packages passes, and test:dist passes 39/39.

  • Public surface: I built both packages at base and head. The ESM/CJS runtime exports and the .d.ts exports only gain 8 names:

    • the five QWP_QUERY_FLAG_* constants
    • QwpEgressRowWindowUnsupportedError
    • decodeQwpEgressMessageWithQueryFlags
    • the type QwpQueryStats

    Switching _core/index.ts to a named export list drops nothing.

  • Wire format: checked against feat(qwp): add error positions, timings, row counts and paging to QWP queries questdb#8356:

    • the order of the trailers
    • the little-endian i32 error position
    • the baseline QUERY_ERROR sent for a request whose flags the server never read
    • the flag bits
    • the 2^63 − 1 bound on skipRows and maxRows
  • Not verified independently: the V8 coverage claim, the 12 real-Chromium tests, and the end-to-end run against a #8356 server build.

Disclosed behaviour changes, confirmed on base and head

  • encodeQwpQueryRequest() now throws in two cases that base encoded:
    • QWP_QUERY_FLAG_SKIP_ROWS or QWP_QUERY_FLAG_MAX_ROWS set without its value. Those bits had no meaning before.
    • a queryFlags that is neither a number nor a bigint.
  • On an endpoint without QUERY_DETAILS, any maxRows or a positive skipRows rejects with QwpEgressRowWindowUnsupportedError. That includes statements, because the client can't tell a statement from a query. QWP.md documents this.

Merge order

#79 and #80 need to land first.

The query details took SERVER_INFO capability 0x08 and query flag 0x02,
which per-query timeouts already use in released clients:
c-questdb-client 7.2.0 and later, and the Python client 6.0.0 and later,
read capability 0x08 as "the server honours a per-query timeout" and
then send flag 0x02 followed by timeout_ms. A server with the query
details would have read that flag as an error-position request, ignored
the timeout and answered with a QUERY_ERROR those clients cannot decode.

QUERY_DETAILS moves to capability 0x10, and the flags to 0x04
(ERROR_POSITION), 0x08 (STATS), 0x10 (COUNT_ROWS), 0x20 (SKIP_ROWS) and
0x40 (MAX_ROWS). The server and the Java client move to the same bits.
A test pins the allocation: every capability and flag is a bit of its
own, and none takes the timeout bits. Another sends a query to a server
that advertises per-query timeouts but not the query details, which
gets none of the details and refuses a row window.

Regenerate the API reference, which shows the constant values, as
CONTRIBUTING.md requires. Most generated-file changes are TypeDoc
source-link refreshes.
The errorPosition docs said an error that does not point into the SQL
text, "such as a cancellation", carries no position. That holds for the
cancellation cancel() sends, which QuestDB answers with position -1, but
not for one on the server: an error the SQL engine raises without a
position of its own reports 0, the start of the text, as /exec does, and
a query timeout on the server and CANCEL QUERY are among them. The client
passes that 0 on, so where the docs promised no position, an application
highlighting error.position marked the start of the SQL.

QWP.md and the TSDoc of errorPosition, QwpEgressQueryError.position and
QwpQueryErrorMessage.position now say which errors report 0 and which
carry no position. Regenerate the API reference as CONTRIBUTING.md
requires. Most generated-file changes are TypeDoc source-link refreshes.
The comment said index 4 sets STATS, which held while STATS was 0x04.
cd427fc moved it to 0x08, so index 4 is now ERROR_POSITION, which
RESULT_END and EXEC_DONE ignore, and the frames a two-parameter
decodeQwpEgressMessage() would misread are indexes 8 to 15. Comment-only
change.
@glasstiger

Copy link
Copy Markdown
Collaborator Author

Review: approve

Scope: PR head 54f04d9e, compared against 6a3615b0. That commit is main with #79 and #80 merged in; its source matches an automatic merge of those two branches' heads, and only generated docs differ. This review covers only this PR's own six commits and leaves #79 and #80 to their own PRs. Generated docs/** and dist/** were not reviewed as source.

The earlier review comment here was written at fca3f659, before cd427fcf moved the capability and flag bits. The current bits match the server side, questdb/questdb#8356 at its head 0db2fa88:

  • capability QUERY_DETAILS is 0x10;
  • the query flags are 0x04 (error position), 0x08 (stats), 0x10 (count rows), 0x20 (skip rows) and 0x40 (max rows), leaving 0x02 and capability 0x08 to per-query timeouts;
  • trailers come in the same order: the row count, then the stats block;
  • the error position is a little-endian i32, and -1 means no position;
  • a request the server rejects before reading its flags gets the baseline QUERY_ERROR, which the client accepts;
  • row-window values are capped at 2^63 − 1 on both sides.

Critical

None.

Moderate

None.

Tests

The test gate passes, with no coverage gaps. I broke the production code in four places at 54f04d9e, and each change made at least one new test fail:

  • reverting the reconnecting transport to the strict decoder;
  • removing the replay withdrawal;
  • resetting the remembered response flags when a query ends;
  • changing position >= 0 to > 0.

Checked at 54f04d9e

  • tsc for the root, test and browser configs, ESLint and Prettier: clean.
  • vitest test/qwp: 1116/1116. test:dist: 39/39. typecheck:dist, including TypeScript 4.9, and check:packages: pass.
  • Public surface: I built both packages at the base and at the head and compared their exports.
    • The ESM and CJS exports gain the same seven names in both packages and lose none: the five new flag constants, QwpEgressRowWindowUnsupportedError and decodeQwpEgressMessageWithQueryFlags. The type QwpQueryStats is also new.
    • decodeQwpEgressEnvelope and validateQwpRowWindowBound stay internal.
  • Failover: a query issued during an outage is re-encoded for the replacement endpoint's capabilities, in both directions, and its trailers decode correctly.
  • Title and description: both follow CONTRIBUTING.md. The description covers user impact, the API additions and the encoder's stricter validation, and the docs and public-api-contract.ts are updated.
  • Not verified: the real-Chromium tests, the Docker ILP integration tests, and an end-to-end run against a server built from #8356.

Disclosed behaviour changes, confirmed

  • encodeQwpQueryRequest() now throws if queryFlags sets the skip or max bit without its value, or is not a number or bigint. Strings were coerced before.
  • On an endpoint without QUERY_DETAILS, any maxRows or a positive skipRows rejects with QwpEgressRowWindowUnsupportedError, statements included. Failover to such an endpoint fails the query instead of replaying it.
  • errorPosition: true returns the bare error message, without the [n] prefix.

QWP.md and the TSDoc of skipRows and maxRows said that statements ignore
the row window, next to the rule that a query with a window fails on an
endpoint without QUERY_DETAILS. The client cannot tell a statement from
a query before sending it, so on such an endpoint a statement with a
positive skipRows or any maxRows fails with
QwpEgressRowWindowUnsupportedError too, and nothing said so.

The docs now say that a server with QUERY_DETAILS ignores the window for
statements, and that a statement is refused the same way as a query on
one without it. QwpEgressRowWindowUnsupportedError says so as well.

Regenerate the API reference as CONTRIBUTING.md requires. Most
generated-file changes are TypeDoc source-link refreshes.
A terminal response that arrived before the socket closed, but that a full
batch pool kept the session from validating, decides whether its query is
replayed: settleLostQuery() leaves the query to it while it waits. Since
38d2063, the session's rejection of such a terminal -- one carrying
trailers its request did not ask for -- goes to the reconnect already under
way, which replays the query. Nothing settled the query again first, so one
that no longer needed a server was replayed too: its consumer had retired
(iterator.return(), a lease release) or its timeout had expired while the
terminal waited. The replacement endpoint ran it only to be sent its
CANCEL, the session's next query waited for that drain -- up to
cancelDrainTimeoutMs, then one more reconnect -- and a timed-out query
reported its timeout one grace period late. Before this branch the
transport rejected such a terminal on arrival, and the query was dropped.

The session now settles the query again once recoverProtocolFailure()
returns, as the reconnect that a rejection found while connected does
through onConnectionLost. A query that no longer needs a server leaves the
replay; one still running is replayed as before. Only a peer that sends
trailers the request did not ask for reaches this path.
countNanos was documented as the time spent counting "the rows a row window
left out", which reads as covering the rows skipRows leaves out, as
rowCount's "left out" does. QuestDB times only the count of the rows past
the end of the window, which it makes when maxRows stops the stream before
the result set ends, and reports zero whenever the stream reads every row.
A window of skipRows alone therefore always reports zero: the skipped rows
are counted as they are skipped, and that time falls within executeNanos.

The TSDoc and QWP.md now say so. Regenerate the API reference as
CONTRIBUTING.md requires. Most generated-file changes are TypeDoc
source-link refreshes.
@glasstiger

Copy link
Copy Markdown
Collaborator Author

Review: approve

Scope: PR head dd91ed54, compared against 6a3615b0. That commit is main with #79 and #80 merged in, and its source matches an automatic merge of those two branches. This review covers only this PR's own commits; #79 and #80 are left to their own PRs. Generated docs/** and dist/** were not reviewed as source.

Critical

None.

Moderate

None.

Minor

  • packages/client-core/src/_qwp/_core/index.ts:5: the durable-ack comment still says "Named, unlike its neighbours". This PR made the egress block right below it (lines 12–32) a named export too, and that block's own comment says "Named, like the ingress codec below". At the base both neighbours were export *. Suggest "Named:" or "Named, like the egress and ingress codecs:".

Tests

The test gate passes, with no coverage gaps. The fix commits' tests catch their regressions:

  • Reverting the recoverProtocolFailure() change from 38d2063 makes all 7 tests in "terminal validation delayed by a full batch pool" fail.
  • Removing the settleLostQuery() call from f3dead7 makes both "does not replay a query … before its terminal is rejected" tests fail.

Checked at dd91ed54

  • tsc for the root, test and browser configs, typecheck:dist including TypeScript 4.9, ESLint, Prettier and check:packages: pass. test:dist: 39/39. vitest test/qwp: 1123/1123.
  • Public surface: the _core barrel's named export list keeps every egress export the base had. decodeQwpEgressEnvelope and validateQwpRowWindowBound stay internal, and public-api.test.ts pins the runtime export lists.
  • Wire format, against feat(qwp): add error positions, timings, row counts and paging to QWP queries questdb#8356 at ffccc616:
    • capability QUERY_DETAILS is 0x10, and the flags are 0x04–0x40, leaving 0x02 and capability 0x08 to per-query timeouts;
    • skip_rows comes before max_rows in the request, and row_count before the statistics block in RESULT_END;
    • the error position is a little-endian i32, and -1 means no position;
    • a request whose flags the server never read gets the baseline QUERY_ERROR, which the client accepts;
    • row-window values are capped at 2^63 − 1 on both sides.

Disclosed behaviour changes, confirmed at base and head

  • encodeQwpQueryRequest() now throws for the skip or max bit without its value, and for a string queryFlags. The base encoded both.
  • On an endpoint without QUERY_DETAILS, a row window is refused with QwpEgressRowWindowUnsupportedError, statements included.
  • A protocol error the session finds after the socket has dropped now reconnects and replays the query, charged to the failover budget. It used to fail the session with QwpSendClosedError.

Not verified

The real-Chromium tests, the Docker ILP integration tests, and an end-to-end run against a server built from #8356.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant