Skip to content

feat: add connection.cancel() and statement.cancel() via SQLCancel#481

Open
rendonvelez wants to merge 4 commits into
IBM:mainfrom
rendonvelez:feature/query-cancellation
Open

feat: add connection.cancel() and statement.cancel() via SQLCancel#481
rendonvelez wants to merge 4 commits into
IBM:mainfrom
rendonvelez:feature/query-cancellation

Conversation

@rendonvelez

Copy link
Copy Markdown

Closes #434.

As discussed in the issue, this adds the ability to cancel in-flight operations. Opening as a draft to gather feedback on the API shape and implementation approach.

API

const connection = await odbc.connect(connectionString);

// cancel the query if it is still running after 10 seconds
setTimeout(() => { connection.cancel(); }, 10000);

try {
  await connection.query('SELECT * FROM HUGE_TABLE');
} catch (error) {
  // cancelled queries reject with SQLSTATE HY008 ("Operation canceled")
}

Both connection.cancel() and statement.cancel() are provided, with the usual dual callback/promise interface.

Implementation

  • connection.cancel(): ODBCConnection keeps a registry of statement handles for in-flight operations (std::set<SQLHSTMT> guarded by a uv_mutex_t, following the existing ODBC::g_odbcMutex pattern). QueryAsyncWorker and CallProcedureAsyncWorker register their handle right after SQLAllocHandle succeeds and deregister it in their destructors. cancel() walks the registry and calls SQLCancel on each handle.
  • statement.cancel(): calls SQLCancel directly on the statement's handle, which lives for the whole lifetime of the Statement object.
  • The cancelled operation returns with SQLSTATE HY008, so its promise rejects (or its callback receives an error) with the standard odbcErrors shape, letting callers distinguish cancellation from other failures.

Why SQLCancel runs synchronously on the main thread

Cancellation is most needed precisely when the libuv thread pool is saturated by the very operations being cancelled — queueing the cancel behind them in an AsyncWorker could delay it indefinitely. Calling SQLCancel from a different thread than the one running the statement is the documented multithreaded-cancel use of the function, and it returns quickly (it only signals the driver).

Scope notes

  • Cursor fetch() calls are not registered in the connection registry in this first pass, so connection.cancel() cancels queries and procedure calls but not an in-flight cursor fetch. Happy to extend if you'd like it covered here.
  • StatementData.hstmt is now initialized to SQL_NULL_HANDLE so cleanup paths that run before the handle is allocated read a defined value.
  • Docs added to the README for both methods, and TypeScript definitions updated.

Testing

All translation units compile cleanly. I still need to run this against a live datasource (Cloudera Impala ODBC driver) to validate end-to-end cancellation behavior — will report results here before marking the PR ready for review. Guidance welcome on how you'd like automated tests structured, given that exercising a real cancel requires a long-running query against a live DSN.

Closes IBM#434.

Add the ability to cancel in-flight operations:

- connection.cancel(): ODBCConnection keeps a registry of statement
  handles for in-flight operations (std::set<SQLHSTMT> guarded by a
  uv_mutex_t). QueryAsyncWorker and CallProcedureAsyncWorker register
  their handle right after allocating it and deregister it in their
  destructors. cancel() walks the registry and calls SQLCancel on each
  handle.

- statement.cancel(): calls SQLCancel directly on the statement's
  handle, which lives for the whole lifetime of the Statement object.

The cancelled operations return with SQLSTATE HY008 ("Operation
canceled"), so their promises reject (or their callbacks receive an
error) and callers can distinguish cancellation from other failures.

SQLCancel is invoked synchronously on the main thread rather than
through an AsyncWorker: cancellation is most needed precisely when the
libuv thread pool is saturated by the operations being cancelled, so
queueing the cancel behind them could delay it indefinitely. Calling
SQLCancel from a different thread than the one running the statement
is the documented multithreaded-cancel use of the function.

StatementData.hstmt is now initialized to SQL_NULL_HANDLE so that
cleanup paths that run before the handle is allocated read a defined
value.

Signed-off-by: Gustavo Rendón Vélez <22383219+rendonvelez@users.noreply.github.com>
Comment thread src/odbc_connection.cpp Outdated
Signed-off-by: Gustavo Rendón Vélez <22383219+rendonvelez@users.noreply.github.com>
The data pointer is always valid: the only constructors of
QueryAsyncWorker and CallProcedureAsyncWorker receive it as a required
argument, so checking it for NULL before unregistering while
dereferencing it unconditionally right after was inconsistent.
Addresses review feedback.

Signed-off-by: Gustavo Rendón Vélez <22383219+rendonvelez@users.noreply.github.com>
@rendonvelez rendonvelez marked this pull request as ready for review July 2, 2026 21:41
@abmusse abmusse added the enhancement New feature or request label Jul 2, 2026
@rendonvelez

Copy link
Copy Markdown
Author

End-to-end validation done. Environment: Cloudera Impala ODBC driver (64-bit) on Windows x64, Node 20, against a production Impala cluster.

Real workload (scan over a large partitioned table):

cancel() sent at 3.78 s
query rejected at 4.01 s, SQLSTATE = HY008

connection.cancel() returned immediately without blocking the main thread, and the in-flight query() promise rejected ~230 ms after the cancel with SQLSTATE HY008, as designed.

One caveat worth documenting: with SELECT SLEEP(70000) the cancel was accepted (and the query still rejected with HY008), but only after the full sleep elapsed — Impala evaluates sleep() without hitting a cancellation checkpoint, so the server doesn't abort mid-sleep. Real scans check for cancellation between row batches and abort promptly. This is engine/driver behavior rather than something this PR can influence (SQLCancel only requests the cancel), but I'm happy to add a note to the README docs if you think it's worth calling out.

From my side the PR is complete — validation done, review feedback applied in 061d352.

@kadler

kadler commented Jul 2, 2026

Copy link
Copy Markdown
Member

This is engine/driver behavior rather than something this PR can influence (SQLCancel only requests the cancel), but I'm happy to add a note to the README docs if you think it's worth calling out.

Yeah, we have the same thing in Db2 (at least on IBM i, not sure other variants) - calls to stored procedures are not cancelable (which makes it hard to test!). This is probably worth calling out in the README.

SQLCancel only requests cancellation; when the operation actually
aborts depends on the engine reaching a cancellation checkpoint. Note
the known cases: Impala's sleep() completes its full wait, and stored
procedure calls on Db2 for IBM i are not cancelable.

Signed-off-by: Gustavo Rendón Vélez <22383219+rendonvelez@users.noreply.github.com>
@rendonvelez

Copy link
Copy Markdown
Author

README note added in 25e61bd, covering both cases: the general "SQLCancel only requests cancellation — the abort happens at the engine's next cancellation checkpoint" behavior, with Impala's sleep() and Db2 for IBM i stored procedure calls as the known examples (thanks for that data point — good to know it's not just Impala). Statement's .cancel() docs reference the same note.

@rendonvelez rendonvelez requested a review from kadler July 3, 2026 18:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request] Support SQLCancel

3 participants