Skip to content

FIX: Release GIL around blocking ODBC statement, fetch, and transaction calls (#540)#541

Draft
saurabh500 wants to merge 1 commit intomainfrom
dev/saurabh/gilrelease
Draft

FIX: Release GIL around blocking ODBC statement, fetch, and transaction calls (#540)#541
saurabh500 wants to merge 1 commit intomainfrom
dev/saurabh/gilrelease

Conversation

@saurabh500
Copy link
Copy Markdown
Contributor

@saurabh500 saurabh500 commented Apr 24, 2026

Work Item / Issue Reference

GitHub Issue: #540
Related: #491, PR #497


Summary

Extends PR #497 (which released the GIL during SQLDriverConnect / SQLDisconnect) to all statement-level blocking ODBC calls so that other Python threads can run while a query is executing on the server.

Problem

Reported in #540 (and seen earlier in #491): while a thread sat inside cursor.execute(...), no other Python code in the process could runasyncio heartbeats, FastAPI/Starlette request handlers, background workers, etc. all stalled for the full duration of the ODBC call. A 5-second WAITFOR DELAY '00:00:05' froze every other thread for the full 5 s.

Root cause: pybind11 holds the Python GIL by default for the entire duration of a C++ function call. Our wrappers around SQLExecute, SQLFetch, SQLEndTran, etc. happily blocked on the network / ODBC driver while still holding the GIL, starving every other Python thread.

PR #497 fixed this for connect/disconnect; this PR extends the same pattern to the statement, fetch, catalog, and transaction paths.

What changed

mssql_python/pybind/ddbc_bindings.cpp — wraps the following blocking ODBC calls with py::gil_scoped_release:

  • Execute path: SQLExecDirect_wrap, SQLExecute_wrap (incl. SQLPrepare and the DAE SQLParamData / SQLPutData loop), SQLExecuteMany_wrap (incl. its DAE loop)
  • Fetch path: SQLFetch_wrap, SQLFetchScroll_wrap, SQLMoreResults_wrap, FetchOne_wrap, FetchBatchData, FetchMany_wrap LOB fallback, FetchAll_wrap LOB fallback, FetchArrowBatch_wrap, FetchLobColumnData (SQLGetData)
  • Catalog wrappers: SQLTables, SQLColumns, SQLPrimaryKeys, SQLForeignKeys, SQLStatistics, SQLProcedures, SQLSpecialColumns, SQLGetTypeInfo

mssql_python/pybind/connection/connection.cpp — wraps the two SQLEndTran calls in Connection::commit() and Connection::rollback().

Why it works (and is safe)

py::gil_scoped_release releases the GIL on construction and re-acquires it in its destructor. The release scope is placed only around the actual blocking ODBC call:

  • All Python-object inspection (py::isinstance, pyObj.cast<>, LOG() which internally re-acquires the GIL) happens outside the release scope, where the GIL is still held.
  • C++ stack-local buffers (std::vector<SQLWCHAR>, std::wstring, paramBuffers, encoded byte buffers) are not Python objects, so they remain valid across the release without GIL protection.
  • For the Data-At-Exec loops we release per individual SQLParamData / SQLPutData call rather than over the whole loop, so per-iteration Python work (extracting the next chunk, encoding, etc.) still runs under the GIL.
  • After the release scope ends the destructor re-acquires the GIL before any subsequent checkError() / LOG() / Python access, matching the established pattern from PR FIX: Release GIL during blocking ODBC connect/disconnect calls #497.

This matches PEP 311 / pybind11's documented contract for releasing the GIL around long-running C/C++ work.

The fix does not relax threadsafety = 1 — sharing a single cursor or connection across threads while a call is in flight is still undefined, exactly as before.

Verification

Repro from #540 (asyncio heartbeat + WAITFOR DELAY '00:00:05'):

Heartbeat behaviour during the 5 s wait
Before one tick at t=0, then 6.0 s gap, then resumes
After ticks every ~1.0 s throughout

Multi-thread scaling (4× concurrent WAITFOR '00:00:02'):

Wall clock
Serial 8.05 s
4 threads, before this fix ~8 s (no parallelism)
4 threads, after this fix 2.02 s (3.98× speedup)

Extends PR #497 (which released the GIL during SQLDriverConnect/
SQLDisconnect) to all statement-level blocking ODBC calls so that
other Python threads can run while a query is executing on the
server.

Wraps the following ODBC calls with py::gil_scoped_release in
mssql_python/pybind/ddbc_bindings.cpp:

- SQLExecDirect_wrap, SQLExecute_wrap (incl. DAE SQLParamData/
  SQLPutData loop), SQLExecuteMany_wrap (incl. DAE loop)
- SQLFetch_wrap, SQLFetchScroll_wrap, SQLMoreResults_wrap
- FetchOne_wrap, FetchBatchData (SQLFetchScroll), FetchMany_wrap
  and FetchAll_wrap LOB fallback paths, FetchArrowBatch_wrap
- FetchLobColumnData (SQLGetData)
- All catalog wrappers: SQLTables, SQLColumns, SQLPrimaryKeys,
  SQLForeignKeys, SQLStatistics, SQLProcedures,
  SQLSpecialColumns, SQLGetTypeInfo

Also wraps SQLEndTran in Connection::commit/rollback in
mssql_python/pybind/connection/connection.cpp.

Verified with an asyncio heartbeat test against
WAITFOR DELAY '00:00:05': heartbeat now ticks every ~1s
throughout the wait (previously stalled for the full 5s).
4 concurrent WAITFOR '00:00:02' queries complete in ~2s (4x
speedup vs serial 8s).

Note on #500: the multi-row INSERT slowness (~14x vs pyodbc) is
CPU-bound parameter binding, not GIL contention; this fix does
not change single-threaded throughput for that workload.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends the existing “release the GIL around blocking ODBC calls” approach (previously applied to connect/disconnect) to statement execution, fetching, catalog metadata calls, and transaction commit/rollback so other Python threads can continue running while the ODBC driver blocks on network/server I/O.

Changes:

  • Release the GIL around blocking statement execution APIs (SQLExecDirect, SQLPrepare, SQLExecute) including Data-At-Exec (SQLParamData / SQLPutData) loops.
  • Release the GIL around fetch/result APIs (SQLFetch, SQLFetchScroll, SQLMoreResults) including LOB streaming via SQLGetData.
  • Release the GIL around transaction completion (SQLEndTran) for commit() / rollback().

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
mssql_python/pybind/ddbc_bindings.cpp Adds py::gil_scoped_release scopes around blocking statement, fetch, LOB, and catalog ODBC calls.
mssql_python/pybind/connection/connection.cpp Adds py::gil_scoped_release scopes around SQLEndTran in commit() and rollback().

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1722 to +1729
SQLRETURN ret;
{
// Release the GIL during the blocking ODBC call so that other Python
// threads (e.g. asyncio event loop, heartbeat threads) can run while
// SQL Server executes the query. See issue #540.
py::gil_scoped_release release;
ret = SQLExecDirect_ptr(StatementHandle->get(), queryPtr, SQL_NTS);
}
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There’s no automated test coverage verifying that the GIL is actually released during statement execution/fetch (e.g., SQLExecDirect/SQLExecute/SQLFetch) and that other Python threads make progress while a query is blocked. The existing stress tests cover connect/disconnect GIL release, but not execute/fetch/transaction paths—so a regression here would be hard to catch. Consider adding a @pytest.mark.stress test similar to tests/test_021_concurrent_connection_perf.py that runs multiple concurrent WAITFOR DELAY executes (and/or a CPU-bound thread heartbeat) and asserts parallelism / non-starvation.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-size: medium Moderate update size

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants