Skip to content

fix(qwp): stop cancel() and grantCredit() throwing after a query ends - #79

Open
glasstiger wants to merge 4 commits into
mainfrom
ia_late_cancel
Open

glasstiger wants to merge 4 commits into
mainfrom
ia_late_cancel

Conversation

@glasstiger

@glasstiger glasstiger commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Once the session had received a query's terminal response, QWP cancel() and grantCredit() threw QwpProtocolError synchronously instead of returning a promise, even while the consumer was still reading buffered batches. A cancel inside the result loop failed with this misleading protocol error. From an abort listener or timer, the throw bypassed .catch() and could crash a Node.js process. The fix applies to both the Node.js and browser clients.

Changes

  • Calls on completed queries now resolve without sending anything, including through queryViews() handles.
  • Control-method failures reject their promises instead of throwing synchronously. Invalid credit amounts reject with RangeError.
  • No extra credit is sent after a query consumer stops reading.
  • Updates API docs and clarifies that restoring an API's promised behavior is a bug fix, not a breaking change.

Validation

Regression tests cover completed queries, view handles, invalid credit, early consumer exit, and closed or failed sessions.

  • Passed: 1,352 Vitest tests, including integration tests, and 39 distribution tests.
  • Passed: typechecks, ESLint, and formatting checks.
  • Not run: real-Chromium tests.

Fixes #76.

@glasstiger glasstiger added the bug Something isn't working label Nov 10, 2026
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.
@glasstiger glasstiger changed the title fix(qwp)!: stop cancel() and grantCredit() throwing after a query ends fix(qwp): stop cancel() and grantCredit() throwing after a query ends Nov 10, 2026
…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.
@glasstiger

Copy link
Copy Markdown
Collaborator Author

Review of 876d7a91 (base 0ce79e1d)

Verdict: approve. No blocking issues; one optional doc-wording suggestion.

Critical

None.

Moderate

None.

Minor

  • packages/client-core/src/_qwp/egress-session.ts:424-425: the new TSDoc on QwpEgressViewQuery.grantCredit says an ended query "is owed none: the grant resolves without sending anything." That isn't always true. On an ended queryViews() query, grantCredit(-1) on either the returned handle or the callback's control rejects with RangeError (observed at head). The linked QwpEgressQuery.grantCredit doc and the next sentence in QWP.md do state the exception, but an IDE hover shows only this summary. Suggested wording: "…resolves without sending anything; an invalid additionalBytes still rejects with a RangeError."

How the fix was verified

  • The QWP egress: cancel() and grantCredit() throw synchronously once a query has ended #76 crash, reproduced as a standalone Node process. The probe completes a query, then calls void query.cancel().catch(() => undefined) from an AbortSignal listener fired from a timer.
    • At base the process exits with code 1: uncaughtException: QwpProtocolError: QWP response references inactive request ID 0.
    • At head it keeps running and sends no frame.
  • Regression tests: with egress-session.ts reverted to base, all 6 new test cases fail and the other 91 pass; at head all pass.
  • Static gates pass: pnpm typecheck, typecheck:qwp-browser, typecheck:test, pnpm eslint, and Prettier on the changed .ts files.
  • Test suites:
    • vitest run test/qwp test/docs-reference.test.ts: 26 files, 1,084 tests, all pass.
    • The other unit suites: 260 pass. The one failure is the ILP interop test, which needs the questdb-client-test submodule; it wasn't checked out in my scratch worktree, and the test fails the same way at base.
  • Mutation checks on the changed guards and try/catch blocks: the new tests catch every mutant except one that only delays when a no-op session.grantCredit() for an ended request settles. That one isn't material, and no change is requested. The returnCredit try/catch this PR removes was dead code.
  • Not run: real-Chromium tests. The change uses no Node- or browser-specific APIs.

Behavior changes and tradeoffs (intentional, not defects)

  • Errors now arrive as rejections. An invalid credit amount, or a control call on a failed or closed session, now rejects instead of throwing synchronously. Code that wrapped an un-awaited call in try/catch will now see an unhandled rejection. This is intended and documented.
  • Numeric strings are refused. At base, query.grantCredit("64") on a running query sent a 64-byte credit; at head it rejects with RangeError (both observed). The session-level QwpEgressSession.grantCredit() doesn't gain that check; it still validates only through the encoder, as before.
  • Unknown request IDs are ignored. The session-level cancel, abandon, grantCredit and rejectView now resolve without doing anything for any request ID that isn't the running query, including IDs the session never issued. At base they failed with a misleading QwpProtocolError: cancel, abandon and grantCredit threw synchronously, and rejectView rejected. This matches the new control contract.
  • Release classification depends on a policy change in this same PR. Under the base CONTRIBUTING.md rule, these behavior changes would count as breaking. The PR replaces that rule, and the PR follows the new one. Maintainers may want to confirm that a new policy can cover the PR that introduces it.

Summary

  • Severity: Critical 0 · Moderate 0 · Minor 1 (in the diff; nothing outside the diff breaks).
  • Submodules: none changed.

@glasstiger

Copy link
Copy Markdown
Collaborator Author

Review of 876d7a91 (base 0ce79e1d)

Verdict: approve. No blocking issues; one optional doc-wording suggestion.

Critical

None.

Moderate

None.

Minor

  • packages/client-core/src/_qwp/egress-session.ts:424-425: the new TSDoc on QwpEgressViewQuery.grantCredit says a grant on an ended query "resolves without sending anything". That isn't true for an invalid amount, because validateCredit runs first.
    • At 876d7a91, grantCredit(-1) and grantCredit(1.5) on an ended queryViews() query reject with RangeError. That happens on the handle queryViews() returns and on the control object the callback receives.
    • This sentence is what an editor shows when hovering control.grantCredit in a callback. Only the linked QwpEgressQuery.grantCredit doc mentions the exception.
    • Suggested wording: "…a valid grant resolves without sending anything; an invalid amount still rejects with a RangeError."

Verification

  • QWP egress: cancel() and grantCredit() throw synchronously once a query has ended #76 reproduced as a standalone Node process. A query completes, then an AbortSignal listener fired by a timer calls void query.cancel().catch(() => undefined). I ran the same with grantCredit(1).
    • At base the process exits with code 1: QwpProtocolError: QWP response references inactive request ID 0.
    • At head it keeps running and sends no frames.
  • The new tests catch the regression.
    • With egress-session.ts reverted to base, all 6 new test cases fail and the other 91 pass.
    • I also removed, one at a time: either terminal-query check, validateCredit, the session-availability check in controlledQuery, and its request-ID comparison. Each change fails at least one new test.
  • Credit frames for a retired query that is still draining. Setup: autoCredit: false, the for await loop exits early, then a late grantCredit(64).
    • Head sends QUERY_REQUEST, CANCEL, CREDIT 110, CREDIT 26, then the next query's QUERY_REQUEST.
    • Base sends the same plus CREDIT 64.
    • The query still drains to QUERY_ERROR and the next query runs.
  • Gates at head pass: pnpm eslint, typecheck, typecheck:test, typecheck:qwp-browser, Prettier on packages/ and test/, vitest run test/qwp (25 files, 1,082 tests) and test/docs-reference.test.ts.
  • Not run: real-Chromium e2e, container integration tests, and test:dist; this PR changes no package boundary.

Coverage

Test gate: pass, no coverage gaps found. Each changed path has a test that fails when its behaviour regresses:

  • both query methods, on running, ended, retired, closed and failed queries;
  • the view handle and the callback's control object;
  • the four session control calls on idle, next-running, closed and failed sessions;
  • invalid amounts.

Behaviour changes callers will notice (deliberate)

  • Failures now reject. Previously they threw synchronously. A call whose promise is neither awaited nor caught, written to rely on the old throw, now produces an unhandled rejection, and on Node that ends the process. An example is an invalid grantCredit() amount whose promise is ignored inside a queryViews() callback; at base the callback wrapper caught that throw.
  • The public session methods cancel, abandon, grantCredit and rejectView now do nothing for any non-active request ID. That includes IDs the session never issued. At base they threw QwpProtocolError.
  • QwpEgressQuery.grantCredit("64") now rejects. At base it sent a 64-byte CREDIT. The session-level grantCredit still accepts a numeric string.

Release classification

Under the base CONTRIBUTING.md, these changes would need fix(qwp)!:. This PR's own policy rewrite is what makes them a plain fix:, and it also reclassifies fixes like #78. Merging under the current title means adopting that policy.

Summary

  • Severity: Critical 0 · Moderate 0 · Minor 1 (in the diff; nothing outside it breaks).
  • Submodules: none changed.

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

bug Something isn't working READY

Projects

None yet

Development

Successfully merging this pull request may close these issues.

QWP egress: cancel() and grantCredit() throw synchronously once a query has ended

1 participant