Skip to content

FIX: Implementing RecordBatchReader.Close() functionality for Arrow - #644

Merged
Subrata (subrata-ms) merged 14 commits into
mainfrom
subrata-ms/bug643
Aug 4, 2026
Merged

FIX: Implementing RecordBatchReader.Close() functionality for Arrow#644
Subrata (subrata-ms) merged 14 commits into
mainfrom
subrata-ms/bug643

Conversation

@subrata-ms

@subrata-msSubrata (subrata-ms) commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Work Item / Issue Reference

AB#45978

GitHub Issue: #643


Summary

This pull request introduces a new _ArrowReader wrapper to enhance the behavior of the arrow_reader method in the Cursor class. The new wrapper ensures that server-side resources are properly released when the reader is closed, supporting robust cleanup and allowing the parent cursor to remain usable. The tests are updated and extended to verify the improved semantics, including close behavior and context manager support.

Enhancements to Arrow batch reading and resource management:

  • Added the _ArrowReader class to cursor.py, which wraps a pyarrow.RecordBatchReader and implements an 8-step close sequence to properly release server-side resources, reset cursor state, and support idempotent and context-manager-based cleanup. The parent Cursor remains usable after closing the reader.
  • Modified the Cursor.arrow_reader method to return an instance of _ArrowReader instead of a raw pyarrow.RecordBatchReader, ensuring that closing the reader stops fetching, releases the server-side cursor, and resets cursor state. [1][2]

Test improvements for Arrow reader behavior:

  • Updated the test_arrow_reader test to check for duck-typed compatibility with pyarrow.RecordBatchReader, reflecting the new wrapper class.
  • Added new tests: test_arrow_reader_close_semantics to verify that .close() stops fetching, marks the reader as closed, is idempotent, and leaves the parent cursor usable; and test_arrow_reader_context_manager to verify that the reader is closed on context manager exit and the cursor remains usable.

@subrata-msSubrata (subrata-ms) changed the title [FIX] Implementing RecordBatchReader.Close() functionality for ArrowFIX: Implementing RecordBatchReader.Close() functionality for ArrowJun 25, 2026
@github-actionsgithub-actionsBot added the pr-size: medium Moderate update size label Jun 25, 2026
@github-actions

github-actionsBot commented Jun 25, 2026

Copy link
Copy Markdown

📊 Code Coverage Report

🔥 Diff Coverage

93%


🎯 Overall Coverage

81%


📈 Total Lines Covered:7184 out of 8797
📁 Project:mssql-python


Diff Coverage

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

  • mssql_python/cursor.py (98.9%): Missing lines 276
  • mssql_python/pybind/ddbc_bindings.cpp (79.4%): Missing lines 1449,1612-1613,1623-1624,1646-1647

Summary

  • Total: 125 lines
  • Missing: 8 lines
  • Coverage: 93%

mssql_python/cursor.py

Lines 272-280

272cursor=self._cursor273ifcursorisnotNoneandnotcursor.closedandcursor.hstmtisnotNone:
274try:
275cursor.hstmt._cancel() # pylint: disable=protected-access
! 276exceptExceptionase: # pylint: disable=broad-exception-caught277logger.debug("arrow_reader.close: SQLCancel raised: %s", e)
278279# Close the generator — this raises GeneratorExit inside it, which280# runs the try/finally cleanup block (SQLFreeStmt + diag drain +

mssql_python/pybind/ddbc_bindings.cpp

Lines 1445-1453

1445 SQLEndTran_ptr = GetFunctionPointer<SQLEndTranFunc>(handle, "SQLEndTran");
1446 SQLDisconnect_ptr = GetFunctionPointer<SQLDisconnectFunc>(handle, "SQLDisconnect");
1447 SQLFreeHandle_ptr = GetFunctionPointer<SQLFreeHandleFunc>(handle, "SQLFreeHandle");
1448 SQLFreeStmt_ptr = GetFunctionPointer<SQLFreeStmtFunc>(handle, "SQLFreeStmt");
! 1449 SQLCancel_ptr = GetFunctionPointer<SQLCancelFunc>(handle, "SQLCancel");
14501451 SQLGetDiagRec_ptr = GetFunctionPointer<SQLGetDiagRecFunc>(handle, "SQLGetDiagRecW");
14521453 SQLParamData_ptr = GetFunctionPointer<SQLParamDataFunc>(handle, "SQLParamData");

Lines 1608-1617

1608 }
1609 }
16101611voidSqlHandle::cancel() {
! 1612// SQLCancel is intentionally lenient: it is a no-op on non-STMT handles,
! 1613// already-freed handles, or if the driver does not expose it. This lets1614// _ArrowReader.close() call it unconditionally without coordinating with1615// the fetch thread. The GIL is released so a blocked fetch thread can1616// observe the cancel and return.1617//

Lines 1619-1628

1619// The only cross-thread pattern this driver blesses is exactly the one1620// ODBC blesses: cancel() may be called from a thread *other than* the1621// fetch thread to unblock an in-flight SQLFetch/SQLExecute on the same1622// HSTMT. Per the ODBC spec, SQLCancel (with the SQLGetDiagRec/Field
! 1623// family) is the only entry point safe to call across threads on the
! 1624// same statement handle. All other operations on a Cursor/SqlHandle1625// are single-owner: per DB API 2.0 and the Cursor thread-safety note1626// in cursor.py, callers must not share a Cursor for its lifecycle1627// operations (execute/fetch/close/free) across threads. Under that1628// contract, free() / close_cursor() / SQLFreeHandle can never be in

Lines 1642-1651

1642if (!SQLCancel_ptr) {
1643return;
1644 }
1645SQLHANDLE h = _handle;
! 1646SQLRETURN ret;
! 1647 {
1648 py::gil_scoped_release release;
1649 ret = SQLCancel_ptr(h);
1650 }
1651// SQLCancel may return SQL_SUCCESS_WITH_INFO when there was nothing to


📋 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.ddbc_bindings.cpp: 76.2%
mssql_python.__init__.py: 77.3%
mssql_python.row.py: 77.6%
mssql_python.ddbc_bindings.py: 79.6%
mssql_python.pybind.connection.connection_pool.cpp: 82.7%
mssql_python.pybind.connection.connection.cpp: 83.7%
mssql_python.connection.py: 84.7%

🔗 Quick Links

⚙️ Build Summary📋 Coverage Details

View Azure DevOps Build

Browse Full Coverage Report

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 improves Arrow streaming ergonomics by making Cursor.arrow_reader() return a RecordBatchReader-compatible wrapper whose .close() actually cancels in-flight fetches and releases server-side ODBC cursor resources, keeping the parent Cursor reusable.

Changes:

  • Introduces _ArrowReader in cursor.py and updates Cursor.arrow_reader() to return it instead of a raw pyarrow.RecordBatchReader.
  • Adds an ODBC SQLCancel binding and exposes it via SqlHandle._cancel() to support cross-thread cancellation during .close().
  • Extends Arrow reader tests to validate close semantics, context-manager behavior, GC cleanup, and cross-thread cancellation.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.

FileDescription
mssql_python/cursor.pyAdds _ArrowReader and reworks arrow_reader() to drive robust cleanup/cancellation and cursor state reset.
mssql_python/pybind/ddbc_bindings.hDeclares the SQLCancel function pointer type and adds SqlHandle::cancel() API surface.
mssql_python/pybind/ddbc_bindings.cppLoads SQLCancel, implements SqlHandle::cancel() (releasing the GIL), and exposes _cancel to Python.
tests/test_004_cursor_arrow.pyUpdates/expands tests to cover new reader wrapper behavior and resource release semantics.

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

Comment threadmssql_python/cursor.py Outdated
Comment threadmssql_python/cursor.py Outdated
Comment threadtests/test_004_cursor_arrow.py
Comment threadtests/test_004_cursor_arrow.py
Comment threadmssql_python/cursor.py
@github-actionsgithub-actionsBot added pr-size: large Substantial code update and removed pr-size: medium Moderate update size labels Jun 25, 2026
@ffelixg

Copy link
Copy Markdown
Contributor

Huh, I didn't know that arrow readers supported a close method! I think that _ArrowReader should be subclassing pyarrow.RecordBatchReader. That way, we won't have to worry about duck typing. It also might save on some implementation overhead. Subrata (@subrata-ms)

@subrata-ms

Copy link
Copy Markdown
ContributorAuthor

Huh, I didn't know that arrow readers supported a close method! I think that _ArrowReader should be subclassing pyarrow.RecordBatchReader. That way, we won't have to worry about duck typing. It also might save on some implementation overhead. Subrata (@subrata-ms)

Thanks for the suggestion — I looked into it and unfortunately subclassing doesn't work cleanly here because pyarrow.RecordBatchReader is a Cython extension type, not a normal Python class. Specifically:

RecordBatchReader.from_batches(...) is a Cython factory that hard-codes the return type to the base class; Sub.from_batches(...) returns a plain RecordBatchReader, so overridden close() / read_next_batch() are never called.
class reassignment is rejected on Cython types (TypeError: class assignment only supported for mutable types or ModuleType subclasses), so downcasting a factory-produced reader isn't an option either.
A Cython instance also rejects arbitrary attributes, so self._cursor, self._generator, etc. can't live on the base instance directly.
The bare Sub.new(Sub) path leaves the underlying C stream unset — super().close() segfaults immediately (I reproduced it locally on pyarrow 24.0.0).
So there's no path where the object is simultaneously (a) an isinstance of pa.RecordBatchReader, (b) able to intercept close() for our SQLCancel + generator teardown, and (c) able to carry our wrapper state.

The current composition/duck-typing shape is intentional for that reason. Happy to add arrow_c_stream to the wrapper so consumers that follow the Arrow PyCapsule Protocol (polars, duckdb, RecordBatchReader.from_stream, etc.) can consume it without any isinstance check — that gives us the "no duck-typing worries" property without the subclass constraints.

subrata-msand others added 2 commits July 29, 2026 07:49
Add __arrow_c_stream__ to the _ArrowReader wrapper so Arrow-aware
consumers (pyarrow.RecordBatchReader.from_stream, polars.from_arrow,
duckdb.from_arrow, ...) can accept it directly without falling back to
isinstance(x, pa.RecordBatchReader) duck-typing.
Subclassing pyarrow.RecordBatchReader (Cython extension type) is not a
viable alternative: from_batches ignores the subclass, __class__
reassignment is rejected on Cython types, instances cannot hold
arbitrary attributes, and a bare Sub.__new__(Sub) segfaults as soon as
any inherited method touches the unset internal C stream. The PyCapsule
Protocol is the modern, standards-based interop mechanism that gives us
the 'no duck-typing worries' property without those constraints.
- Delegates to self._inner.__arrow_c_stream__ (pyarrow >= 14).
- Raises ArrowInvalid('Reader is closed') post-close, matching the
read_next_batch / schema semantics.
- Fails explicitly with a clear message on pyarrow < 14 instead of
silently returning an invalid capsule.
- Extends the class docstring to advertise PyCapsule support and to
explain why subclassing was ruled out (for future reviewers).
- Adds two tests: PyCapsule Protocol round-trip via
pa.RecordBatchReader.from_stream (skipped on pyarrow < 14) and a
post-close guard.
Comment threadmssql_python/cursor.py
Comment threadmssql_python/pybind/ddbc_bindings.cpp
Comment threadmssql_python/cursor.py
Comment threadmssql_python/cursor.py

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

suggesting a better impl of the wrapper

Comment threadmssql_python/cursor.py
Comment threadmssql_python/pybind/ddbc_bindings.cpp Fixed
Comment threadmssql_python/cursor.py Outdated
Comment threadmssql_python/cursor.py
Subrata (subrata-ms)and others added 3 commits August 3, 2026 22:30
…O from thread-safety comment
- cursor.py: change Cursor.arrow_reader return annotation from
"pyarrow.RecordBatchReader" to "_ArrowReader" (the actual returned
type). Update docstring to say the returned object *is* the wrapper
rather than "behaves like" one.
- mssql_python.pyi: mirror the annotation change and add a minimal
_ArrowReader class stub documenting the wrapper's explicitly-owned
surface (closed, schema, read_next_batch, close, __arrow_c_stream__,
iteration and context-manager dunders) plus __getattr__ for runtime
delegation to the wrapped pyarrow.RecordBatchReader.
- ddbc_bindings.cpp: reword the DevSkim-flagged "TODO" inside the
SqlHandle::cancel cross-thread-invariant comment to "note". The
reference still points at the same thread-safety comment block in
cursor.py; no code change.
Add 9 targeted tests for the previously-uncovered defensive branches of
the Arrow reader's cancel/close path so the PR's newly-added code is
covered by pytest rather than only the happy paths.
_ArrowReader coverage (lines 167, 196, 213, 231, 237-238):
- __getattr__ refuses leading-underscore names to prevent __del__ recursion.
- __enter__ on a closed reader raises ArrowInvalid.
- __arrow_c_stream__ raises ArrowInvalid when the inner reader lacks the
PyCapsule Protocol (pyarrow < 14 fallback path).
- __del__ short-circuits when sys.is_finalizing() is True.
- __del__ swallows any exception raised inside the finalizer body.
arrow_reader() cleanup generator coverage (lines 3046, 3052, 3060, 3082, 3091):
- Finally block early-returns when the parent Cursor is already closed.
- Pre- and post-close DDBCSQLGetAllDiagRecords failures are swallowed.
- hstmt._close_cursor() failure is swallowed and cleanup continues to the
bookkeeping-reset step.
- Cursor._clear_rownumber() failure is swallowed.
The four cleanup tests use a dedicated fresh mssql_python.connect() so the
shared module-scoped 'cursor' fixture cannot hit 'connection is busy with
results for another command' from residual state left by an earlier test.
No production code changes.
@subrata-ms
Subrata (subrata-ms) merged commit 0c230f9 into mainAug 4, 2026
29 checks passed
@subrata-msSubrata (subrata-ms) mentioned this pull request Aug 6, 2026
Subrata (subrata-ms) added a commit that referenced this pull request Aug 7, 2026
### Work Item / Issue Reference
>
[AB#47087](https://sqlclientdrivers.visualstudio.com/c6d89619-62de-46a0-8b46-70b92a84d85e/_workitems/edit/47087)
-------------------------------------------------------------------
### Summary
Release mssql-python v1.13.0.
Version bump to 1.13.0. Updates `mssql_python/__init__.py`, `setup.py`,
`PyPI_Description.md`, and the README "Important Note" section. Bundled
`mssql_py_core` bumped from 0.1.7 to 0.1.8 (no source/API changes —
dev-nightly to stable pin).
#### Enhancements
- **ODBC driver ships exclusively via `mssql-python-odbc` (Phase 2)** —
The `libs/` fallback introduced in v1.12.0 has been removed.
`mssql-python` now hard-depends on `mssql-python-odbc==18.6.2.1`; `pip
install mssql-python` still pulls the driver package transparently.
Smaller wheels; driver binaries managed independently (#693).
- **Apache Arrow bulk copy** — New `Cursor.bulkcopy_arrow(table_name,
source)` method for high-performance bulk loading from `pyarrow.Table` /
`RecordBatch` / Arrow C Data Interface sources; classic `bulkcopy()` now
raises `TypeError` for Arrow inputs and steers users to the new method
(#665).
- **`token_provider=` parameter for Azure Identity credentials** —
`connect()` accepts any credential with a `.get_token(scope)` method
(`DefaultAzureCredential`, `AzureCliCredential`,
`ManagedIdentityCredential`, …). Mutually exclusive with
`Authentication=` in the connection string (#603, issue #577).
- **Identity-aware connection pooling with token-expiry refresh** — Pool
now keys on security context, preventing cross-identity connection
leaks; token acquisition deferred to pool-misses; connections with
near-expiry tokens refreshed automatically (#660, issues #651, #659).
#### Bug Fixes
- **Silent zero-row `executemany` batches on late NULLs** — Fixed
numeric array parameter binding paths
(`TINYINT`/`SMALLINT`/`INT`/`FLOAT`) that left indicator slots
uninitialized when a NULL appeared partway through the batch (#702,
issue #670).
- **`SQL_WVARCHAR` output converter applied as catch-all to non-string
columns** — Fallback now gated on `str`/`bytes` mapped types (#692,
issue #691).
- **Integer-keyed output converters silently never fired** —
`add_output_converter(SQL_DECIMAL, ...)` and other integer SQL type code
keys now dispatch correctly (pyodbc parity) (#690, issue #684).
- **`RecordBatchReader.Close()` for Arrow result sets** —
`Cursor.arrow_reader()` now returns a wrapper whose `.close()` releases
server-side resources and leaves the parent cursor usable (#644, issue
#643).
- **`AttributeError` in `Cursor.__del__` on partially-initialized
cursor** — `__init__` sets `closed`/`hstmt` before any raise; `__del__`
uses correct `sys.is_finalizing()` guard (#646, issue #642).
#### Version Bump
- `mssql_python/__init__.py`: `__version__ = "1.13.0"`
- `setup.py`: `version="1.13.0"`
- `PyPI_Description.md`: `## What's new in v1.13.0` section refreshed
- `README.md`: "Important Note" updated for Phase 2 (no more `libs/`
fallback, `mssql-python-odbc==18.6.2.1`)
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-size: largeSubstantial code update

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@subrata-ms@ffelixg@bewithgaurav@github-advanced-security@sumitmsft