Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
87442b0
fix(qwp): stop cancel() and grantCredit() throwing after a query ends
glasstiger Nov 10, 2026
b387a3c
docs(contributing): stop counting bug fixes as breaking changes
glasstiger Nov 10, 2026
6b24458
test(qwp): check that a late session control call leaves the running …
glasstiger Nov 10, 2026
876d7a9
test(qwp): cover idle-session, closed-session and running-query contr…
glasstiger Nov 10, 2026
8508928
fix(qwp): close browser WebSockets that refuse the client's close code
glasstiger Nov 11, 2026
af65bde
docs(qwp): correct the ws remark in the startClosingHandshake() comment
glasstiger Nov 11, 2026
aa5fb8b
test(qwp): cover a refused non-1000 close in the Node terminate test
glasstiger Nov 11, 2026
c5fe758
fix(qwp): report browser durable-ACK refusal without waiting for the …
glasstiger Nov 11, 2026
11f34dd
docs(qwp): tie the cancel-timeout error to the drain bound in QWP.md
glasstiger Nov 11, 2026
6a3615b
Merge branch 'ia_late_cancel' into ia_query_info
glasstiger Nov 12, 2026
4e8de12
feat(qwp): add error positions, timings, row counts and paging to que…
glasstiger Nov 12, 2026
4f6fe54
test(qwp): pin the boundaries of the query-details coverage
glasstiger Nov 12, 2026
fca3f65
fix(qwp): keep decodeQwpEgressMessage() safe to pass to map()
glasstiger Nov 12, 2026
cd427fc
fix(qwp): move query details off the per-query timeout bits
glasstiger Nov 13, 2026
8073b12
docs(qwp): say which query errors report position 0
glasstiger Nov 13, 2026
54f04d9
test(qwp): name the indexes that set STATS in the map() test comment
glasstiger Nov 13, 2026
38d2063
fix(qwp): recover deferred terminal failures during reconnect
glasstiger Nov 14, 2026
45b7ac6
docs(qwp): say that a row window fails statements without QUERY_DETAILS
glasstiger Nov 14, 2026
f3dead7
fix(qwp): do not replay a released query after rejecting its terminal
glasstiger Nov 15, 2026
dd91ed5
docs(qwp): say which rows QwpQueryStats.countNanos counts
glasstiger Nov 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
50 changes: 43 additions & 7 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,12 +178,12 @@ and dispatch only the first again. The second then ships under the newer
version.

`check-release-versions.mjs` decides *whether* to bump, never by how much.
Pick the level from the changes the release carries: a change that alters the
behaviour of a published API, its emitted wire bytes, or the errors it throws
is breaking and needs a major bump, even when the new behaviour is a fix. The
signal comes from the commits, so mark a breaking change where Conventional
Commits puts it (see below) rather than leaving it for the release to
rediscover.
Pick the level from the changes the release carries: a breaking change, as
described under "Breaking Changes" below, needs a major bump. A bug fix that
brings an API back to its contract does not, even when callers can observe
the difference. The signal comes from the commits, so mark a breaking change
where Conventional Commits puts it (see below) rather than leaving it for the
release to rediscover.

### npm authentication

Expand Down Expand Up @@ -257,7 +257,8 @@ We follow the [Conventional Commits](https://www.conventionalcommits.org/) speci
A commit that breaks a published API marks it, either as `!` after the
type/scope (`feat(qwp)!: ...`) or as a `BREAKING CHANGE:` footer. That marker
is what tells a release it has to be a major one, so a pull request carrying
such a change needs it on the pull request title too.
such a change needs it on the pull request title too. "Breaking Changes" below
describes what counts as one.

3. Push your changes to your fork:

Expand All @@ -267,6 +268,41 @@ git push origin feature/your-feature-name

4. Create a Pull Request from your fork to the main repository.

## Breaking Changes

A change is breaking when code that relies on a published API's contract --
its documentation, its types, and what it evidently promises -- has to change
to keep working. Whether a caller can observe a difference does not decide
it. Breaking changes include:

- removing or renaming an export, option or connect-string key, changing a
default, or narrowing a signature or type;
- changing documented behaviour, such as the error class a documented failure
raises, or refusing input the documentation accepts;
- making the client send something a supported server version rejects.

A bug fix that brings the implementation back to its contract is not
breaking, even though callers can observe the difference. Commit it as a
plain `fix:`, without `!` or a `BREAKING CHANGE:` footer, and describe what
callers will notice in the commit body and the pull request. Such fixes
include:

- a method that returns a promise but threw synchronously, and now rejects or
resolves instead;
- a crash, hang, unhandled rejection or uncaught exception that becomes an
ordinary result or an error the caller can catch;
- an error raised for something the contract does not treat as a failure,
such as cancelling a query that has already ended, or an error whose class
names the wrong cause;
- input the documentation accepts that the code refused or mishandled, such
as data that fits within `max_buf_size`.

The wording of an error message is not part of the contract; the error's
class, and the conditions documented for it, are. A fix that also changes
documented behaviour is breaking all the same. If it is unclear whether the
old behaviour belonged to the contract, say so in the pull request and let
the reviewers decide.

## Pull Request Guidelines

1. Make sure all tests pass
Expand Down
105 changes: 102 additions & 3 deletions QWP.md
Original file line number Diff line number Diff line change
Expand Up @@ -1533,7 +1533,11 @@ statement that completed anyway, or a result that ended with nothing withheld,
succeeds. A query sends at most one `CANCEL`. `cancel()` settles once its `CANCEL`
has been sent, or once a reconnect has taken it over: the frame then follows the
replayed request, so a callback that awaits `cancel()` does not hold up the
reconnect that has to drain it first.
reconnect that has to drain it first. Once a query has ended, `cancel()` and
`grantCredit()` resolve without sending anything, even if the session has since
closed or failed, so a timer or an `AbortSignal` listener that fires after
completion may still call them. Neither throws: every failure, such as an invalid
credit amount, rejects the promise it returns.

`await query.awaitCompletion(timeoutMs)` bounds only the caller's wait and returns
`false` without cancelling when the timeout expires, matching Java
Expand All @@ -1553,8 +1557,12 @@ client discards, and ends a round trip after that. Without flow control
(`initialCredit: 0`, the default) QuestDB does not read the connection while it
streams a result: it sends the rest of the result before it sees the `CANCEL`, and
the client discards it. If the server does not terminate the query within the bound,
its connection is closed, which is what ends the query on the server. A query ended
with `query.cancel()` then fails with `QwpEgressQueryCancelTimeoutError`; one already
its connection is closed, which is what ends the query on the server. A browser
cannot drop a connection itself, only send a close and wait for the server's reply.
Without flow control QuestDB reads that close only once the query has finished or
failed, so in a browser closing does not stop such a query until the browser abandons
the closing handshake, after 60 seconds in Chromium. When the bound expires, a query
ended with `query.cancel()` fails with `QwpEgressQueryCancelTimeoutError`; one already
released by its timeout or by breaking out of `for await` keeps the error it was
released with. A reconnecting session, the default, opens a new connection for the
next query instead of failing. This client-initiated replacement emits `disconnected`
Expand Down Expand Up @@ -1663,6 +1671,96 @@ const session = await connectQwpBrowserEgress({
});
```

### Error positions, statistics, row counts and paging

Per-query options ask QuestDB for what `/exec` reports besides the rows: where an
error points in the SQL text, how long the query took, and how many rows a result
has while it is read one page at a time. They need a server whose `SERVER_INFO`
advertises the `QUERY_DETAILS` capability (`QWP_EGRESS_CAPABILITY.QUERY_DETAILS`).

```typescript
import { QwpEgressQueryError } from "@questdb/browser-client";

const pageSize = 100;
const offset = 2 * pageSize;
try {
const page = await session.query(sql, {
skipRows: offset,
maxRows: pageSize,
countRows: true,
stats: true,
errorPosition: true,
});
for await (const batch of page) render(batch);
const completion = await page.completion;
if (completion.kind === "result-end") {
// totalRows counts the rows of this page, rowCount those of the result.
showPager(offset, completion.totalRows, completion.rowCount);
}
showTimings(completion.stats);
} catch (error) {
if (error instanceof QwpEgressQueryError && error.position !== undefined) {
highlight(sql, error.position, error.message);
}
throw error;
}
```

- `errorPosition: true` makes a `QwpEgressQueryError` carry `position`, the UTF-16
offset into the SQL text the error points at. That is an index into the
JavaScript string the query was issued with, so `sql.slice(error.position)`
starts where the error does, whatever non-ASCII text precedes it. The message is
then bare, without the `[n]` prefix, a position or an error number, that QuestDB
otherwise puts in front of it. An error the SQL engine raises without a position
of its own reports 0, the start of the text, as `/exec` does: a query timeout on
the server and a query cancelled there with `CANCEL QUERY` are among them. An
error that does not come from the SQL engine, such as a protocol error, a row
too large for the server's send buffer or the cancellation `cancel()` sends,
carries no position, and neither does the error for a request the server
rejected before reading its options.
- `stats: true` adds `stats` to the completion of a result (`RESULT_END`) and of a
statement (`EXEC_DONE`), as `QwpQueryStats`: `compileNanos`, zero when the
server reused a cached plan; `executeNanos`, from the server receiving the
request to its terminal response, the other durations included; `countNanos`,
spent counting the rows past the end of the row window for `countRows`, which
is zero unless `maxRows` ended the stream first; `clientWaitNanos`, the time
the query waited for this client to read results or grant credit; and
`jitCompiled`, whether its filter ran as JIT-compiled native code. Errors carry
no statistics.
- `countRows: true` adds `rowCount` to a result's completion: the rows in the
whole result set, whatever `skipRows` and `maxRows` leave out, while
`totalRows` still counts the rows the stream carried. When `maxRows` ends the
stream first, the server counts the remaining rows without sending them, which
can take as long as reading them and runs under the server's query timeout.
- `skipRows` and `maxRows` bound the rows a query streams, like SQL
`LIMIT skipRows, skipRows + maxRows` around it: the first `skipRows` rows are
left out, and at most `maxRows` follow. `maxRows: 0` streams only the schema, in
one empty batch. Both take a non-negative safe integer or a `bigint` up to
2^63 - 1. A server with `QUERY_DETAILS` ignores the window and the row count
for statements.

The client drops `errorPosition`, `stats` and `countRows` on an endpoint without
`QUERY_DETAILS`, as it drops `resetDictionary` on one without `QUERY_FLAGS`, so
they are safe during a rolling upgrade: the reports are simply absent. A row
window cannot be dropped that way, because such a server would stream the whole
result. A query with a positive `skipRows` or any `maxRows` therefore rejects with
`QwpEgressRowWindowUnsupportedError` before anything is sent, and so does a
statement: the client cannot tell the two apart before sending them. Failover
applies the same rules: a replay asks the endpoint it reaches for the details that
endpoint supports, and a query whose row window it cannot honour fails with
`QwpEgressRowWindowUnsupportedError` instead of being replayed. Check
`session.serverInfo.capabilities & QWP_EGRESS_CAPABILITY.QUERY_DETAILS` to choose
between a row window and SQL `LIMIT` in advance.

The low-level `encodeQwpQueryRequest()` takes the raw flags
(`QWP_QUERY_FLAG_ERROR_POSITION`, `QWP_QUERY_FLAG_STATS`,
`QWP_QUERY_FLAG_COUNT_ROWS`, `QWP_QUERY_FLAG_SKIP_ROWS` and
`QWP_QUERY_FLAG_MAX_ROWS`) together with the `skipRows` and `maxRows` values the
last two require, and `decodeQwpEgressMessageWithQueryFlags(bytes, queryFlags)`
reads the trailers those flags add to `RESULT_END`, `EXEC_DONE` and
`QUERY_ERROR`. `decodeQwpEgressMessage(bytes)` keeps its single parameter, so it
stays safe to pass straight to `map()`, and rejects those trailers.

## Combined pooled client

Use `QwpClient` when one long-lived application component needs both ingestion
Expand Down Expand Up @@ -1990,6 +2088,7 @@ The public error classes preserve enough context for policy decisions:
| `QwpEgressQueryAbandonedError` | Result iteration ended before the server completed the query |
| `QwpEgressQueryTimeoutError` | The query ran past its timeout and was cancelled |
| `QwpEgressQueryCancelTimeoutError` | A cancelled query did not produce a terminal server response before the drain deadline |
| `QwpEgressRowWindowUnsupportedError` | A query asked an endpoint without the `QUERY_DETAILS` capability for a row window (`skipRows`, `maxRows`) |

Always close senders and sessions in `finally`. For a standalone sender, publication
plus ACK draining is bounded by `closeFlushTimeoutMs`; the subsequent WebSocket closing
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,16 @@ reusable buffer pool. Views are invalid when their callback returns; copy a byte
view with `.slice()` or call `batch.materialize()` inside the callback to retain
data. Tune the default four-slot pool with the session's `bufferPoolSize`.

A query can also ask for what `/exec` reports besides the rows. With
`errorPosition: true` a `QwpEgressQueryError` carries `position`, the offset into
the SQL string the error points at; `stats: true` adds compile, execution and
client-wait times and JIT use to the completion as `stats`; and `countRows: true`
adds the size of the whole result as `rowCount`. `skipRows` and `maxRows` read a
result one page at a time, like SQL `LIMIT`. These options need a server that
advertises the `QUERY_DETAILS` capability; see
[QWP.md](QWP.md#error-positions-statistics-row-counts-and-paging) for how older
servers are handled.

`queryTimeoutMs` (connect-string key `query_timeout_ms`) sets the session's
default query timeout; a per-query `timeoutMs` overrides it, and zero disables
it. The timeout runs from the `query()` call and covers the whole query. When it
Expand Down
2 changes: 1 addition & 1 deletion docs/assets/hierarchy.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading