Skip to content

FEAT: async POC#677

Draft
subrata-ms wants to merge 8 commits into
mainfrom
subrata-ms/AsyncQueryImpl
Draft

FEAT: async POC#677
subrata-ms wants to merge 8 commits into
mainfrom
subrata-ms/AsyncQueryImpl

Conversation

@subrata-ms

@subrata-ms subrata-ms commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

This pull request adds support for detecting and caching ODBC driver async capabilities, which is a foundational step for enabling asynchronous operations (such as execute_async) on SQL Server connections. The changes ensure that the driver’s support for asynchronous execution is probed once per connection and cached for efficient future checks. Additionally, the connection string parsing is updated to clarify and safely handle the enabling of Multiple Active Result Sets (MARS).

Async capability detection and caching:

  • Added an atomic cache (_asyncModeCache) in the Connection class to store the result of probing SQLGetInfo(SQL_ASYNC_MODE), minimizing redundant driver queries and improving performance for async operations.
  • Implemented the isAsyncCapable() method in both Connection and ConnectionHandle, which checks and caches whether the driver supports statement-level or connection-level async, and is used as a capability gate before enabling async features. [1] [2] [3]
  • Included necessary header for atomic operations.

Connection string handling improvements:

  • Updated the connection string allowlist in constants.py to explicitly document and support the ODBC-standard MARS_Connection parameter, while warning against using the SQL Server-native MultipleActiveResultSets alias unless driver support is confirmed, preventing user confusion and unpredictable behavior.

Work Item / Issue Reference

AB#<WORK_ITEM_ID>

GitHub Issue: #<ISSUE_NUMBER>


Summary

Introduces the foundational C++ building blocks for the upcoming
Cursor.execute_async / Cursor.fetch_async work. No user-facing API is
exposed yet; sync execute/fetch paths are functionally unchanged.

Helpers added in ddbc_bindings.cpp:
- struct PollingConfig (initial_ms=0.5, max_ms=20, multiplier=1.5)
- class AsyncEnableGuard: RAII sets/clears SQL_ATTR_ASYNC_ENABLE on an
  HSTMT so the async flag never leaks onto a handle reused by later sync
  calls.
- template<Fn> ExecuteWithPolling(callOnce, asyncMode, cfg): sync
  passthrough when asyncMode=false; polling loop with exponential
  backoff when asyncMode=true. Caller owns GIL release.
- PrepareAndBind(...): factored out of SQLExecute_wrap (SQLPrepare under
  released GIL, DescribeCache clear, isStmtPrepared flag update,
  BindParameters). Enables reuse from upcoming async execute wrap.

SQLExecute_wrap refactored to route both branches through the helpers
with asyncMode=false (semantic no-op vs. the previous inline calls).
DAE loop unchanged.

Capability probe:
- Connection::isAsyncCapable() / ConnectionHandle::isAsyncCapable()
  probes SQLGetInfo(SQL_ASYNC_MODE), caches the value in an atomic int
  (SQL_AM_STATEMENT / SQL_AM_CONNECTION => true). Exposed to Python as
  Connection.is_async_capable().

Build: clean under -Werror -Wattributes -Wint-to-pointer-cast.
@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown

📊 Code Coverage Report

🔥 Diff Coverage

72%


🎯 Overall Coverage

80%


📈 Total Lines Covered: 6973 out of 8673
📁 Project: mssql-python


Diff Coverage

Diff: main...HEAD, staged and unstaged changes

  • mssql_python/cursor.py (59.1%): Missing lines 3530,3541,3579,3581,3585-3586,3594-3597,3599,3606,3613,3622-3623,3661,3663-3664,3674,3676,3692-3697,3715-3717,3851,3887,3892-3893,3899,3901-3904,3916,3918,3920-3921,3925-3927,3929-3930,3932,3934,3937-3942,3955,3957-3958,3962-3964,3966-3967,3969,3971
  • mssql_python/pybind/connection/connection.cpp (61.8%): Missing lines 646-647,649-651,657,659-661,672-673,679-680
  • mssql_python/pybind/ddbc_bindings.cpp (82.5%): Missing lines 1849-1851,1859-1860,1885-1887,1893-1894,1903-1907,1914-1915,2021-2022,2038-2039,2041-2042,2048-2050,2052-2053,2201-2204,2214,4179-4182,4770-4771,6031-6032

Summary

  • Total: 427 lines
  • Missing: 119 lines
  • Coverage: 72%

mssql_python/cursor.py

Lines 3526-3534

  3526         per connection.
  3527         """
  3528         conn = self._connection._conn
  3529         if not conn.is_async_capable():
! 3530             raise NotSupportedError(
  3531                 driver_error=(
  3532                     "Async execution is not supported by this ODBC driver. "
  3533                     "SQLGetInfo(SQL_ASYNC_MODE) reports SQL_AM_NONE."
  3534                 ),

Lines 3537-3545

  3537 
  3538     def _acquire_async_slot(self) -> None:
  3539         """Take the cursor-busy slot; raise ProgrammingError if already held."""
  3540         if self._async_in_flight:
! 3541             raise ProgrammingError(
  3542                 driver_error=(
  3543                     "Cursor is busy: another async operation is already in flight "
  3544                     "on this cursor. One Cursor == one HSTMT, so async ops on the "
  3545                     "same cursor must be serialized. Create a second cursor for "

Lines 3575-3590

  3575         if reset_cursor:
  3576             if self.hstmt:
  3577                 self._soft_reset_cursor()
  3578             else:
! 3579                 self._reset_cursor()
  3580         else:
! 3581             if self.hstmt:
  3582                 logger.debug(
  3583                     "execute_async: Closing cursor for re-execution (reset_cursor=False)"
  3584                 )
! 3585                 self.hstmt._close_cursor()
! 3586                 self._clear_rownumber()
  3587 
  3588         # Clear any previous messages
  3589         self.messages = []

Lines 3590-3603

  3590 
  3591         # Parameter unwrap (same rules as sync execute).
  3592         if parameters:
  3593             if isinstance(parameters, tuple) and len(parameters) == 1:
! 3594                 if isinstance(parameters[0], (tuple, list, dict)):
! 3595                     actual_params = parameters[0]
! 3596                 elif isinstance(parameters[0], Row):
! 3597                     actual_params = tuple(parameters[0])
  3598                 else:
! 3599                     actual_params = parameters
  3600             else:
  3601                 actual_params = parameters
  3602 
  3603             if operation == self.last_executed_stmt and isinstance(

Lines 3602-3610

  3602 
  3603             if operation == self.last_executed_stmt and isinstance(
  3604                 actual_params, (tuple, list)
  3605             ):
! 3606                 parameters = list(actual_params)
  3607             else:
  3608                 operation, converted_params = detect_and_convert_parameters(
  3609                     operation, actual_params
  3610                 )

Lines 3609-3617

  3609                     operation, actual_params
  3610                 )
  3611                 parameters = list(converted_params)
  3612         else:
! 3613             parameters = []
  3614 
  3615         encoding_settings = self._get_encoding_settings()
  3616 
  3617         logger.debug("execute_async: Creating parameter type list")

Lines 3618-3627

  3618         param_info = ddbc_bindings.ParamInfo
  3619         parameters_type: List[Any] = []
  3620 
  3621         if parameters and self._inputsizes:
! 3622             if len(self._inputsizes) != len(parameters):
! 3623                 warnings.warn(
  3624                     f"Number of input sizes ({len(self._inputsizes)}) does not match "
  3625                     f"number of parameters ({len(parameters)}). "
  3626                     f"This may lead to unexpected behavior.",
  3627                     Warning,

Lines 3657-3668

  3657         DELIBERATE DUPLICATE of the corresponding logic in ``execute()``.
  3658         """
  3659         try:
  3660             check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret)
! 3661         except Exception as e:  # pylint: disable=broad-exception-caught
  3662             logger.warning("execute_async failed, resetting cursor: %s", e)
! 3663             self._reset_cursor()
! 3664             raise
  3665 
  3666         self._capture_diagnostics(ret)
  3667         self.last_executed_stmt = operation
  3668         self.rowcount = ddbc_bindings.DDBCSQLRowCount(self.hstmt)

Lines 3670-3680

  3670         column_metadata: List[Any] = []
  3671         try:
  3672             ddbc_bindings.DDBCSQLDescribeCol(self.hstmt, column_metadata)
  3673             self._initialize_description(column_metadata)
! 3674         except Exception:  # pylint: disable=broad-exception-caught
  3675             # If describe fails, it's likely there are no results (e.g. INSERT)
! 3676             self.description = None
  3677 
  3678         if self.description:
  3679             self.rowcount = -1
  3680             self._reset_rownumber()

Lines 3688-3701

  3688             )
  3689             self._cached_converter_map = self._build_converter_map()
  3690             self._uuid_str_indices = self._compute_uuid_str_indices()
  3691         else:
! 3692             self.rowcount = ddbc_bindings.DDBCSQLRowCount(self.hstmt)
! 3693             self._clear_rownumber()
! 3694             self._cached_column_map = None
! 3695             self._cached_column_map_lower = None
! 3696             self._cached_converter_map = None
! 3697             self._uuid_str_indices = None
  3698 
  3699         self._reset_inputsizes()
  3700 
  3701     def _wrap_row_async(self, row_data: List[Any]) -> Row:

Lines 3711-3721

  3711         )
  3712 
  3713     def _wrap_rows_async(self, rows_data: List[List[Any]]) -> List[Row]:
  3714         """Async-only mirror of fetchmany() / fetchall()'s Row-construction block."""
! 3715         column_map, converter_map, column_map_lower = self._get_column_and_converter_maps()
! 3716         uuid_idx = self._uuid_str_indices
! 3717         return [
  3718             Row(
  3719                 row_data,
  3720                 column_map,
  3721                 cursor=self,

Lines 3847-3855

  3847         self._check_closed()
  3848         self._check_async_capable()
  3849 
  3850         if not self._has_result_set and self.description:
! 3851             self._reset_rownumber()
  3852 
  3853         char_decoding = self._get_decoding_settings(ddbc_sql_const.SQL_CHAR.value)
  3854         wchar_decoding = self._get_decoding_settings(ddbc_sql_const.SQL_WCHAR.value)
  3855         char_enc = char_decoding.get("encoding", "utf-16le")

Lines 3883-3897

  3883                 )
  3884 
  3885             if ret == ddbc_sql_const.SQL_NO_DATA.value:
  3886                 if self._next_row_index == 0 and self.description is not None:
! 3887                     self.rowcount = 0
  3888                 return None
  3889 
  3890             # rownumber tracking (mirrors sync fetchone)
  3891             if self._skip_increment_for_next_fetch:
! 3892                 self._skip_increment_for_next_fetch = False
! 3893                 self._next_row_index += 1
  3894             else:
  3895                 self._increment_rownumber()
  3896             self.rowcount = self._next_row_index
  3897             return self._wrap_row_async(row_data)

Lines 3895-3908

  3895                 self._increment_rownumber()
  3896             self.rowcount = self._next_row_index
  3897             return self._wrap_row_async(row_data)
  3898 
! 3899         if size == -1:
  3900             # ---- fetchall-async ----
! 3901             rows_data: List[List[Any]] = []
! 3902             self._acquire_async_slot()
! 3903             try:
! 3904                 ret = await loop.run_in_executor(
  3905                     None,
  3906                     ddbc_bindings.DDBCSQLFetchAllAsync,
  3907                     self.hstmt,
  3908                     rows_data,

Lines 3912-3946

  3912                     poll_initial_ms,
  3913                     poll_max_ms,
  3914                 )
  3915             finally:
! 3916                 self._release_async_slot()
  3917 
! 3918             check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret)
  3919 
! 3920             if self.hstmt:
! 3921                 self.messages.extend(
  3922                     ddbc_bindings.DDBCSQLGetAllDiagRecords(self.hstmt)
  3923                 )
  3924 
! 3925             if rows_data and self._has_result_set:
! 3926                 self._next_row_index += len(rows_data)
! 3927                 self._rownumber = self._next_row_index - 1
  3928 
! 3929             if len(rows_data) == 0 and self._next_row_index == 0:
! 3930                 self.rowcount = 0
  3931             else:
! 3932                 self.rowcount = self._next_row_index
  3933 
! 3934             return self._wrap_rows_async(rows_data)
  3935 
  3936         # ---- fetchmany-async (size > 0 or size <= 0 sentinel) ----
! 3937         if size <= 0:
! 3938             return []
! 3939         rows_data = []
! 3940         self._acquire_async_slot()
! 3941         try:
! 3942             ret = await loop.run_in_executor(
  3943                 None,
  3944                 ddbc_bindings.DDBCSQLFetchManyAsync,
  3945                 self.hstmt,
  3946                 rows_data,

Lines 3951-3972

  3951                 poll_initial_ms,
  3952                 poll_max_ms,
  3953             )
  3954         finally:
! 3955             self._release_async_slot()
  3956 
! 3957         if self.hstmt:
! 3958             self.messages.extend(
  3959                 ddbc_bindings.DDBCSQLGetAllDiagRecords(self.hstmt)
  3960             )
  3961 
! 3962         if rows_data and self._has_result_set:
! 3963             self._next_row_index += len(rows_data)
! 3964             self._rownumber = self._next_row_index - 1
  3965 
! 3966         if len(rows_data) == 0 and self._next_row_index == 0:
! 3967             self.rowcount = 0
  3968         else:
! 3969             self.rowcount = self._next_row_index
  3970 
! 3971         return self._wrap_rows_async(rows_data)

mssql_python/pybind/connection/connection.cpp

Lines 642-655

  642 }
  643 
  644 // Async POC: probe SQL_ASYNC_MODE and cache the result. Returns true iff the
  645 // driver supports statement-level async (SQL_AM_STATEMENT) or connection-level
! 646 // async (SQL_AM_CONNECTION). Called from Cursor.execute_async as a capability
! 647 // gate before configuring SQL_ATTR_ASYNC_ENABLE on the statement handle.
  648 bool Connection::isAsyncCapable() const {
! 649     int cached = _asyncModeCache.load(std::memory_order_acquire);
! 650     if (cached < 0) {
! 651         if (!_dbcHandle) {
  652             return false;
  653         }
  654         if (!SQLGetInfo_ptr) {
  655             LOG("isAsyncCapable: SQLGetInfo not initialized, loading driver");

Lines 653-665

  653         }
  654         if (!SQLGetInfo_ptr) {
  655             LOG("isAsyncCapable: SQLGetInfo not initialized, loading driver");
  656             DriverLoader::getInstance().loadDriver();
! 657         }
  658         SQLUSMALLINT asyncMode = 0;
! 659         SQLSMALLINT outLen = 0;
! 660         SQLRETURN ret = SQLGetInfo_ptr(_dbcHandle->get(), SQL_ASYNC_MODE, &asyncMode,
! 661                                        sizeof(asyncMode), &outLen);
  662         if (!SQL_SUCCEEDED(ret)) {
  663             LOG("isAsyncCapable: SQLGetInfo(SQL_ASYNC_MODE) failed - SQLRETURN=%d", ret);
  664             // Cache as SQL_AM_NONE so we don't re-probe on every call.
  665             _asyncModeCache.store(static_cast<int>(SQL_AM_NONE), std::memory_order_release);

Lines 668-677

  668         cached = static_cast<int>(asyncMode);
  669         _asyncModeCache.store(cached, std::memory_order_release);
  670         LOG("isAsyncCapable: SQL_ASYNC_MODE=%d (cached)", cached);
  671     }
! 672     return cached == static_cast<int>(SQL_AM_STATEMENT) ||
! 673            cached == static_cast<int>(SQL_AM_CONNECTION);
  674 }
  675 
  676 bool ConnectionHandle::isAsyncCapable() const {
  677     if (!_conn) {

Lines 675-684

  675 
  676 bool ConnectionHandle::isAsyncCapable() const {
  677     if (!_conn) {
  678         ThrowStdException("Connection object is not initialized");
! 679     }
! 680     return _conn->isAsyncCapable();
  681 }
  682 
  683 void ConnectionHandle::setAttr(int attribute, py::object value) {
  684     if (!_conn) {

mssql_python/pybind/ddbc_bindings.cpp

Lines 1845-1855

  1845         }
  1846     }
  1847 
  1848     ~AsyncEnableGuard() {
! 1849         if (_enabled && _hStmt && SQLSetStmtAttr_ptr) {
! 1850             SQLRETURN rc = SQLSetStmtAttr_ptr(
! 1851                 _hStmt, SQL_ATTR_ASYNC_ENABLE,
  1852                 reinterpret_cast<SQLPOINTER>(static_cast<uintptr_t>(SQL_ASYNC_ENABLE_OFF)),
  1853                 0);
  1854             if (!SQL_SUCCEEDED(rc)) {
  1855                 LOG("AsyncEnableGuard: SQLSetStmtAttr(SQL_ATTR_ASYNC_ENABLE, OFF) "

Lines 1855-1864

  1855                 LOG("AsyncEnableGuard: SQLSetStmtAttr(SQL_ATTR_ASYNC_ENABLE, OFF) "
  1856                     "failed - SQLRETURN=%d, hStmt=%p",
  1857                     rc, (void*)_hStmt);
  1858             }
! 1859         }
! 1860     }
  1861 
  1862     bool enabled() const { return _enabled; }
  1863 
  1864     AsyncEnableGuard(const AsyncEnableGuard&) = delete;

Lines 1881-1891

  1881 // `callOnce` is any callable returning SQLRETURN (typically a lambda that
  1882 // captures the statement handle and calls SQLExecute / SQLExecDirect / SQLFetch).
  1883 template <typename Fn>
  1884 inline SQLRETURN ExecuteWithPolling(Fn callOnce, bool asyncMode,
! 1885                                      const PollingConfig& cfg = PollingConfig{}) {
! 1886     SQLRETURN ret = callOnce();
! 1887     if (!asyncMode) {
  1888         return ret;
  1889     }
  1890     double sleep_ms = cfg.initial_ms;
  1891     while (ret == SQL_STILL_EXECUTING) {

Lines 1889-1898

  1889     }
  1890     double sleep_ms = cfg.initial_ms;
  1891     while (ret == SQL_STILL_EXECUTING) {
  1892         std::this_thread::sleep_for(
! 1893             std::chrono::microseconds(static_cast<long long>(sleep_ms * 1000.0)));
! 1894         sleep_ms = std::min(sleep_ms * cfg.multiplier, cfg.max_ms);
  1895         ret = callOnce();
  1896     }
  1897     return ret;
  1898 }

Lines 1899-1911

  1899 
  1900 // Prepares the statement (if requested) and binds all parameters. Returns
  1901 // the SQLRETURN from the last ODBC call; on non-success the caller should
  1902 // short-circuit before invoking SQLExecute.
! 1903 //
! 1904 // `paramBuffers` is an OUT parameter: it accumulates heap-owned buffers that
! 1905 // MUST remain in scope until SQLExecute completes, because ODBC keeps raw
! 1906 // pointers into them across the SQLBindParameter / SQLExecute boundary.
! 1907 //
  1908 // GIL: SQLPrepare is called with the GIL released (matches previous inline
  1909 // behavior); BindParameters requires the GIL (inspects py::list contents).
  1910 SQLRETURN PrepareAndBind(SqlHandlePtr statementHandle, SQLHANDLE hStmt, SQLWCHAR* queryPtr,
  1911                          const py::list& params, std::vector<ParamInfo>& paramInfos,

Lines 1910-1919

  1910 SQLRETURN PrepareAndBind(SqlHandlePtr statementHandle, SQLHANDLE hStmt, SQLWCHAR* queryPtr,
  1911                          const py::list& params, std::vector<ParamInfo>& paramInfos,
  1912                          py::list& isStmtPrepared, bool usePrepare,
  1913                          std::vector<std::shared_ptr<void>>& paramBuffers,
! 1914                          const std::string& charEncoding) {
! 1915     // isStmtPrepared is a single-element list carrying a bool by reference
  1916     // (Python bools are immutable, so we can't pass the raw bool by ref).
  1917     assert(isStmtPrepared.size() == 1);
  1918 
  1919     SQLRETURN rc = SQL_SUCCESS;

Lines 2017-2026

  2017             // Release the GIL during the blocking ODBC call. In async mode
  2018             // ExecuteWithPolling re-invokes SQLExecDirect while it returns
  2019             // SQL_STILL_EXECUTING, with backoff sleeps between polls.
  2020             py::gil_scoped_release release;
! 2021             rc = ExecuteWithPolling(
! 2022                 [&]() { return SQLExecDirect_ptr(hStmt, queryPtr, SQL_NTS); },
  2023                 asyncMode, pollCfg);
  2024         }
  2025         if (!SQL_SUCCEEDED(rc) && rc != SQL_NO_DATA) {
  2026             LOG("SQLExecute: Direct execution failed (non-parameterized query) "

Lines 2034-2046

  2034         if (encodingSettings.contains("encoding")) {
  2035             charEncoding = encodingSettings["encoding"].cast<std::string>();
  2036         }
  2037 
! 2038         // This vector manages the heap memory allocated for parameter buffers.
! 2039         // It must remain in scope until SQLExecute (and any DAE loop) completes.
  2040         std::vector<std::shared_ptr<void>> paramBuffers;
! 2041         rc = PrepareAndBind(statementHandle, hStmt, queryPtr, params, paramInfos,
! 2042                             isStmtPrepared, usePrepare, paramBuffers, charEncoding);
  2043         if (!SQL_SUCCEEDED(rc)) {
  2044             return rc;
  2045         }

Lines 2044-2057

  2044             return rc;
  2045         }
  2046 
  2047         {
! 2048             // Release the GIL during the blocking SQLExecute network call. In
! 2049             // async mode ExecuteWithPolling handles SQL_STILL_EXECUTING with
! 2050             // backoff sleeps between polls.
  2051             py::gil_scoped_release release;
! 2052             rc = ExecuteWithPolling([&]() { return SQLExecute_ptr(hStmt); },
! 2053                                     asyncMode, pollCfg);
  2054         }
  2055         if (rc == SQL_NEED_DATA) {
  2056             LOG("SQLExecute: SQL_NEED_DATA received - Starting DAE "
  2057                 "(Data-At-Execution) loop for large parameter streaming");

Lines 2197-2208

  2197                           py::list& isStmtPrepared, const bool usePrepare,
  2198                           const py::dict& encodingSettings) {
  2199     return SQLExecute_impl(statementHandle, query, params, paramInfos, isStmtPrepared,
  2200                            usePrepare, encodingSettings,
! 2201                            /*asyncMode=*/false, PollingConfig{});
! 2202 }
! 2203 
! 2204 // Async wrapper (exposed as DDBCSQLExecuteAsync). Called from Cursor.execute_async
  2205 // via loop.run_in_executor so the polling loop runs on a background thread with
  2206 // the GIL released, keeping the asyncio event loop responsive.
  2207 SQLRETURN SQLExecuteAsync_wrap(const SqlHandlePtr statementHandle, const std::u16string& query,
  2208                                const py::list& params, std::vector<ParamInfo>& paramInfos,

Lines 2210-2218

  2210                                const py::dict& encodingSettings, double poll_initial_ms,
  2211                                double poll_max_ms) {
  2212     PollingConfig cfg;
  2213     cfg.initial_ms = poll_initial_ms;
! 2214     cfg.max_ms = poll_max_ms;
  2215     // multiplier stays at the PollingConfig default (1.5)
  2216     return SQLExecute_impl(statementHandle, query, params, paramInfos, isStmtPrepared,
  2217                            usePrepare, encodingSettings, /*asyncMode=*/true, cfg);
  2218 }

Lines 4175-4186

  4175 }
  4176 
  4177 // Fetch rows in batches
  4178 // TODO: Move to anonymous namespace, since it is not used outside this file
! 4179 //
! 4180 // Async POC: when asyncMode=true, the SQLFetchScroll network call is driven
! 4181 // through ExecuteWithPolling so it can return SQL_STILL_EXECUTING. The caller
! 4182 // is responsible for having installed SQL_ATTR_ASYNC_ENABLE on hStmt (via
  4183 // AsyncEnableGuard) before calling this function.
  4184 SQLRETURN FetchBatchData(SQLHSTMT hStmt, ColumnBuffers& buffers, py::list& columnNames,
  4185                          py::list& rows, SQLUSMALLINT numCols, SQLULEN& numRowsFetched,
  4186                          const std::vector<SQLUSMALLINT>& lobColumns,

Lines 4766-4775

  4766     cfg.initial_ms = poll_initial_ms;
  4767     cfg.max_ms = poll_max_ms;
  4768     return FetchMany_impl(StatementHandle, rows, fetchSize, charEncoding, wcharEncoding,
  4769                           charCtype, /*asyncMode=*/true, cfg);
! 4770 }
! 4771 
  4772 // GetDataVar - Progressively fetches variable-length column data using SQLGetData.
  4773 //
  4774 // Calls SQLGetData repeatedly, reallocating the buffer as needed, until all data is retrieved.
  4775 // Handles both fixed-size and unknown-size (SQL_NO_TOTAL) responses from the driver.

Lines 6027-6036

  6027 }
  6028 
  6029 // Sync wrapper (exposed as DDBCSQLFetchOne). Existing pybind binding target;
  6030 // keeps the original signature and default arguments unchanged.
! 6031 SQLRETURN FetchOne_wrap(SqlHandlePtr StatementHandle, py::list& row,
! 6032                         const std::string& charEncoding = "utf-16le",
  6033                         const std::string& wcharEncoding = "utf-16le",
  6034                         int charCtype = SQL_C_WCHAR) {
  6035     return FetchOne_impl(StatementHandle, row, charEncoding, wcharEncoding, charCtype,
  6036                          /*asyncMode=*/false, PollingConfig{});


📋 Files Needing Attention

📉 Files with overall lowest coverage (click to expand)
mssql_python.pybind.logger_bridge.cpp: 59.2%
mssql_python.pybind.ddbc_bindings.h: 59.9%
mssql_python.pybind.logger_bridge.hpp: 70.8%
mssql_python.pybind.connection.connection.cpp: 75.4%
mssql_python.pybind.ddbc_bindings.cpp: 76.4%
mssql_python.__init__.py: 77.3%
mssql_python.row.py: 77.6%
mssql_python.ddbc_bindings.py: 79.6%
mssql_python.connection.py: 83.6%
mssql_python.logging.py: 85.5%

🔗 Quick Links

⚙️ Build Summary 📋 Coverage Details

View Azure DevOps Build

Browse Full Coverage Report

Adds the four statement-level async pybind entry points that
Cursor.execute_async / Cursor.fetch_async will call from Python via
loop.run_in_executor. Sync entry points (DDBCSQLExecute /
DDBCSQLFetchOne|Many|All) are unchanged in signature and behavior.

Refactor pattern for each of the four sync wraps:
- *_wrap body extracted into static *_impl(..., bool asyncMode,
  const PollingConfig& pollCfg).
- _impl installs AsyncEnableGuard(hStmt, asyncMode) at the top so
  SQL_ATTR_ASYNC_ENABLE is toggled on entry and reset on exit; the
  guard is a no-op when asyncMode=false.
- Every SQLExecute / SQLExecDirect / SQLFetch / SQLFetchScroll call site
  now goes through ExecuteWithPolling(cb, asyncMode, pollCfg). Sync
  callers pass asyncMode=false which collapses to a straight one-shot
  call (semantic identity with previous inline behavior).
- *_wrap becomes a thin sync forwarder that calls _impl with
  asyncMode=false. Existing pybind bindings and callers unchanged.
- *Async_wrap is a new thin forwarder that builds PollingConfig from the
  Python-supplied poll_initial_ms / poll_max_ms and calls _impl with
  asyncMode=true.

Additional plumbing:
- FetchBatchData (internal helper shared by FetchMany_impl and
  FetchAll_impl non-LOB paths) gained defaulted asyncMode + pollCfg
  params; its SQLFetchScroll_ptr call is now routed through
  ExecuteWithPolling. All existing sync callers pass the defaults.
- SQLExecute_impl rejects Data-At-Execution (isDAE=true) parameters
  up-front when asyncMode=true - the SQL_NEED_DATA loop is not safe to
  drive alongside SQL_STILL_EXECUTING polling. Callers must fall back
  to sync execute for large VARBINARY(MAX) / NVARCHAR(MAX) values.

New pybind exports (each with trailing poll_initial_ms=0.5,
poll_max_ms=20.0 float args):
- DDBCSQLExecuteAsync
- DDBCSQLFetchOneAsync
- DDBCSQLFetchManyAsync
- DDBCSQLFetchAllAsync

Out of scope for POC (still sync-only): SQLGetData_wrap (LOB streaming),
DDBCSQLFetchArrowBatch. Documented inline in the _impl bodies.

Build: clean under -Werror -Wattributes -Wint-to-pointer-cast. All four
async bindings + all existing sync bindings verified importable.
Adds the Python-facing async API on top of the C++ statement-level async
scaffolding delivered in steps 1 and 2. The change is 100% additive to
cursor.py (+483 lines, 0 removed): the sync execute / fetchone /
fetchmany / fetchall implementations are byte-identical to main and are
not touched.

Design decision: DUPLICATE, don't refactor.
- The prep and finalize logic that the async path needs is deliberately
  duplicated from the sync execute() implementation instead of extracted
  into shared helpers. Rationale: the sync methods are hot, well-tested,
  and touching them for an experimental POC risks subtle regressions
  that only surface in edge cases. Long-term de-duplication is deferred
  until the async POC stabilizes.

Additions on Cursor:
- self._async_in_flight: bool guard added to __init__ (only sync-code
  change, purely additive) - prevents overlapping async ops on the same
  HSTMT (one Cursor == one HSTMT).
- _check_async_capable() - raises NotSupportedError when the driver
  reports SQL_AM_NONE (uses cached is_async_capable() from step 1).
- _acquire_async_slot() / _release_async_slot() - raise ProgrammingError
  on cursor-busy overlap.
- _prepare_execute_state_async(op, parameters, use_prepare, reset_cursor)
  - deliberate duplicate of execute()'s pre-ODBC prep block.
- _finalize_execute_async(ret, operation) - deliberate duplicate of
  execute()'s post-ODBC finalize block.
- _wrap_row_async(row_data) / _wrap_rows_async(rows_data) - deliberate
  duplicates of the Row-construction blocks in fetchone / fetchmany /
  fetchall.

Public async surface:
- async execute_async(operation, *parameters, use_prepare=True,
  reset_cursor=True, poll_initial_ms=0.5, poll_max_ms=20.0) -> Cursor
  offloads DDBCSQLExecuteAsync via loop.run_in_executor. asyncio is
  imported lazily inside the method so sync-only users pay nothing.
- async fetch_async(size=None, *, poll_initial_ms=0.5, poll_max_ms=20.0)
  -> Row | List[Row] | None
    size=None  -> DDBCSQLFetchOneAsync   -> Row or None
    size=-1    -> DDBCSQLFetchAllAsync   -> List[Row]
    size>0     -> DDBCSQLFetchManyAsync  -> List[Row]
    size<=0    -> [] (matches sync fetchmany semantics)

Concurrency contract:
- Same-cursor async ops are serialized via _async_in_flight; overlap
  raises ProgrammingError('Cursor is busy...').
- Cross-cursor async ops on the same connection are allowed (each cursor
  has its own HSTMT).

Not covered in this POC (documented in the tail block header):
- Data-At-Execution parameters (rejected up-front in C++).
- asyncio.CancelledError propagation (needs SQLCancelHandle).
- executemany_async / arrow_batch_async / catalog method async.

Verification:
- Diff is 100% additive (+483 / -0). Sync methods byte-identical to main.
- Module imports cleanly; all 9 new members present on Cursor.
- execute_async and fetch_async both verified as coroutine functions.
- Signatures match the spec exactly.

Not run: functional end-to-end tests (no DB in this env).
…etch_async

Adds tests/test_030_async_execute_fetch.py with three tests covering the
Python-facing async surface introduced by the previous three commits
(is_async_capable probe, DDBCSQL*Async pybind wrappers, and the public
Cursor.execute_async / Cursor.fetch_async methods).

Tests:
  1. test_capability_probe_returns_true
       Sanity check on Connection.is_async_capable() - fails loudly on a
       regression in the C++ SQLGetInfo(SQL_ASYNC_MODE) probe rather than
       silently skipping the whole module.

  2. test_single_execute_async_and_fetch_async
       End-to-end smoke: one execute_async with two parameters, one
       fetch_async that returns the row, and a second fetch_async that
       must return None.

  3. test_100_concurrent_async_selects
       Fires 100 concurrent execute_async + fetch_async pairs through
       asyncio.gather with a bounded Semaphore(16). One dedicated
       connection per task so we don't hit SQL Server's default no-MARS
       restriction. Each task sends its index as a parameter and asserts
       the returned row matches - proves per-HSTMT isolation
       (no cross-wiring between concurrent statements) and that the
       AsyncEnableGuard toggling on/off around each call does not leak
       across cursors.

Design decisions:
  - No pytest-asyncio dependency; each test uses asyncio.run(...) inline.
    Keeps requirements.txt unchanged.
  - Skips cleanly when DB_CONNECTION_STRING is unset OR the driver
    reports SQL_ASYNC_MODE == SQL_AM_NONE.
  - Concurrency knobs env-overridable for CI tuning:
    ASYNC_TEST_CONCURRENCY (default 100),
    ASYNC_TEST_MAX_INFLIGHT (default 16),
    ASYNC_TEST_WALL_BUDGET  (default 120s).

Run against SQL Server 2022 (mcr.microsoft.com/mssql/server:2022-latest)
in a local Docker container: all 3 tests pass, 100 concurrent async
queries complete in 0.75s wall clock.
…ntly skips)

Complements test_100_concurrent_async_selects (one connection per task) with a
same-connection MARS variant that opens ONE connection with
MultipleActiveResultSets=Yes and creates N cursors on it. Both variants share
the same correctness assertion (each task must return its own index), but
exercise different bug classes:

  * connection-per-task  proves cross-DBC async isolation and true
                         network-level parallelism.
  * MARS same-connection proves per-HSTMT AsyncEnableGuard toggling doesn't
                         cross-wire result sets between cursors that share
                         a single DBC.

Current status: the mssql-python connection-string parser rejects both
MultipleActiveResultSets and MARS_Connection - neither keyword is in
_ALLOWED_CONNECTION_STRING_PARAMS (mssql_python/constants.py). The test
therefore skips today with a clear message pointing at the exact underlying
error. When MARS is added to the allowlist the test will start running
automatically with no further changes.

Also added a _with_mars() helper that appends the keyword unless the caller
already set MARS explicitly (either enabled or disabled).

Verified against SQL Server 2022 in local Docker: 3 pass, 1 skips cleanly.
…mment

While investigating why the MARS variant test skips, we prototyped adding
MultipleActiveResultSets / MARS_Connection to _ALLOWED_CONNECTION_STRING_PARAMS
to see if the test would pass. It did not — the combination

    MARS + SQL_ATTR_ASYNC_ENABLE + concurrent execute across multiple
    HSTMTs on one DBC (concurrency >= 2)

crashes the interpreter with SIGSEGV inside the ODBC driver / C++ layer.
Reproduces reliably at concurrency=2, N=5 on msodbcsql18 against SQL
Server 2022. Sequential MARS-async and Semaphore=1 (serialized) both work
fine — the failure is specific to multi-threaded concurrent SQLExecute on
HSTMTs that share a single DBC under async mode.

The MARS allowlist prototype was reverted (constants.py untouched — 0-line
delta vs main). This commit updates the WARNING block in the MARS test to
document the finding so a future maintainer knows why the keyword must NOT
be added to the mssql-python allowlist until either:

  * the driver-side race is root-caused and fixed, OR
  * mssql-python detects a MARS DBC in execute_async and either serializes
    per-DBC via a lock, or raises NotSupportedError.

Until then, the connection-per-task pattern (test 3) remains the supported
way to run concurrent async workloads.

Test suite behavior unchanged: 3 pass, 1 skip.
Adds a second wave of functional tests exercising the async surface across
long queries, large result sets, event-loop non-blocking behavior,
multi-batch fetches, and sequential vs concurrent invocation patterns.
All safe patterns use one connection per concurrent task (see root-cause
investigation on why cross-cursor concurrent async on a shared DBC is
unsafe until per-Connection serialization is added).

New tests:

  Case 1: test_execute_async_long_running_query
    2-second server-side WAITFOR through execute_async; verifies the
    polling loop survives many SQL_STILL_EXECUTING iterations without
    losing the result set.

  Case 2: test_event_loop_progresses_during_execute_async
    Background heartbeat coroutine (10ms tick) runs while a 2s WAITFOR
    query executes. Requires >= 60 ticks. Local run: 185 ticks. Proves
    the event loop is NOT starved by the polling loop.

  Case 4: test_1000_async_executes_on_different_connections
    Scale test_100_concurrent_async_selects up to 1000 tasks. Marked
    @pytest.mark.stress (excluded from default run per pytest.ini).
    Local run: 1000 queries in 2.44s under Semaphore(16).

  Case 6: test_fetch_async_returns_batch_of_rows
    fetch_async(size=5) returns exactly 5 Row objects with correct values.

  Case 7: test_fetch_async_large_result_set
    5000-row SELECT via CROSS JOIN of sys.all_objects; fetch_async(-1)
    returns all rows with correct sequential numbering.

  Case 8: test_event_loop_progresses_during_fetch_async
    Symmetric to Case 2 but on the fetch path. Uses HB_FETCH_ROWS (default
    50000) so the fetch is long enough for meaningful heartbeat ticks.
    Skips inconclusively if the fetch finishes in <50ms (fast local DB).

  Case 9: test_multiple_concurrent_execute_async_small_batch
    10-task variant of test_100_concurrent_async_selects; kept small for
    quick smoke runs.

  Case 10: test_execute_async_followed_by_fetch_async
    All three fetch_async modes (size=None, positive int, -1) on the same
    cursor after independent executes.

  Case 11: test_multiple_concurrent_fetch_async_across_connections
    Two-phase: (1) N=20 connections each execute a query sequentially,
    (2) fetch_async fired concurrently across all N via asyncio.gather.
    Verifies the fetch code path under concurrent gather.

  Case 12: test_sequential_execute_async_on_same_cursor
    Five sequential execute_async calls on the same cursor. Verifies that
    AsyncEnableGuard correctly toggles SQL_ATTR_ASYNC_ENABLE OFF at the
    end of each call so subsequent executes are not affected by leftover
    state.

Env-overridable knobs added:
  ASYNC_TEST_LARGE_ROWS          rows for the large-result-set test (5000)
  ASYNC_TEST_HB_FETCH_ROWS       rows for fetch-heartbeat test (50000)
  ASYNC_TEST_STRESS_CONCURRENCY  N for the 1000-task stress test (1000)
  ASYNC_TEST_WAITFOR_SECONDS     server-side delay for long-query tests (2)

Verified against SQL Server 2022 in local Docker:
  12 passed, 1 skipped (MARS), 1 deselected (stress) in 4.58s;
  stress test explicitly: 1 passed in 2.47s.
…tability test

Root-cause fix for the MARS variant test that was skipping. Investigation
found that the SQL Server-native connection-string keyword
'MultipleActiveResultSets' is SILENTLY IGNORED by the bundled msodbcsql18
driver (empirically verified: keyword is accepted but MARS is never
actually enabled). Only the ODBC-standard alias 'MARS_Connection' turns
MARS on. The earlier segfault we saw when prototyping MARS in the
allowlist was because MARS was silently off — 100 concurrent async
statements on a non-MARS DBC hit the 'connection busy' error path, and
concurrent DDBCSQLCheckError calls raced on shared DBC diagnostic state.

With MARS genuinely on (this commit), concurrent async on shared DBC
works cleanly. 100 truly-concurrent execute_async + fetch_async pairs on
ONE MARS connection completes in ~130ms with no crash, no error, no
cross-wiring. Verified across 10 iterations on the same connection.

Changes:

  mssql_python/constants.py (+13 lines)
    Adds "mars_connection": "MARS_Connection" to
    _ALLOWED_CONNECTION_STRING_PARAMS. Detailed comment warns future
    maintainers NOT to add 'multipleactiveresultsets' without confirming
    driver honors it (currently doesn't). This also unblocks MARS for
    sync code — users can now do multiple active cursors on one
    connection with 'MARS_Connection=Yes'.

  tests/test_030_async_execute_fetch.py (+193/-35)
    * _with_mars() helper now appends 'MARS_Connection=Yes' (the working
      keyword) instead of 'MultipleActiveResultSets=Yes' (silently
      ignored).
    * Block comment above test_100_concurrent_async_selects_on_single_mars_connection
      rewritten: replaces the outdated 'MARS segfault' warning with a
      history / root-cause fix note explaining what was actually broken.
    * Test docstring updated to remove 'skipped today' language.
    * NEW test_mars_stability_100_cursors_high_concurrency_multi_iteration
      (@pytest.mark.stress): 10 iterations of 100 concurrent async
      cursors on ONE MARS connection, no semaphore, verifying
      per-iteration correctness AND long-term stability. Local run: 1.33s
      total, per-iteration timing consistent 126-160ms across all 10
      rounds (no drift, no leak, no cross-wiring).
    * Env knobs: ASYNC_TEST_MARS_STABILITY_N (default 100),
      ASYNC_TEST_MARS_STABILITY_ITERS (default 10).

Key finding for future work: MARS gives client-side result-set
multiplexing over ONE TCP socket, but SQL Server assigns one worker
thread per session (SPID). So N concurrent statements on one MARS
connection queue up server-side, not truly parallel. Real network-level
parallelism still requires N separate connections
(test_100_concurrent_async_selects pattern).

Verified against SQL Server 2022 in local Docker:
  15/15 tests pass (13 non-stress + 2 stress) in 10.17s total.
  MARS variant test: 100 concurrent async in 0.13s (was skipping).
  MARS stability test: 10 iters × 100 cursors in 1.33s (new).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant