FIX: Release GIL around blocking ODBC statement, fetch, and transaction calls (#540)#541
FIX: Release GIL around blocking ODBC statement, fetch, and transaction calls (#540)#541saurabh500 wants to merge 1 commit intomainfrom
Conversation
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>
There was a problem hiding this comment.
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 viaSQLGetData. - Release the GIL around transaction completion (
SQLEndTran) forcommit()/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.
| 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); | ||
| } |
There was a problem hiding this comment.
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.
Work Item / Issue Reference
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 run —asyncioheartbeats, FastAPI/Starlette request handlers, background workers, etc. all stalled for the full duration of the ODBC call. A 5-secondWAITFOR 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 withpy::gil_scoped_release:SQLExecDirect_wrap,SQLExecute_wrap(incl.SQLPrepareand the DAESQLParamData/SQLPutDataloop),SQLExecuteMany_wrap(incl. its DAE loop)SQLFetch_wrap,SQLFetchScroll_wrap,SQLMoreResults_wrap,FetchOne_wrap,FetchBatchData,FetchMany_wrapLOB fallback,FetchAll_wrapLOB fallback,FetchArrowBatch_wrap,FetchLobColumnData(SQLGetData)SQLTables,SQLColumns,SQLPrimaryKeys,SQLForeignKeys,SQLStatistics,SQLProcedures,SQLSpecialColumns,SQLGetTypeInfomssql_python/pybind/connection/connection.cpp— wraps the twoSQLEndTrancalls inConnection::commit()andConnection::rollback().Why it works (and is safe)
py::gil_scoped_releasereleases the GIL on construction and re-acquires it in its destructor. The release scope is placed only around the actual blocking ODBC call:py::isinstance,pyObj.cast<>,LOG()which internally re-acquires the GIL) happens outside the release scope, where the GIL is still held.std::vector<SQLWCHAR>,std::wstring,paramBuffers, encoded byte buffers) are not Python objects, so they remain valid across the release without GIL protection.SQLParamData/SQLPutDatacall rather than over the whole loop, so per-iteration Python work (extracting the next chunk, encoding, etc.) still runs under the GIL.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'):Multi-thread scaling (4× concurrent
WAITFOR '00:00:02'):