Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
288 changes: 281 additions & 7 deletions mssql_python/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,217 @@ def _normalize_time_param(value, c_type):
return None


class _ArrowReader:
"""RecordBatchReader-compatible wrapper that makes ``close()`` actually
release server-side resources.

``pyarrow.RecordBatchReader.from_batches(...)`` returns a reader whose
``close()`` only releases the internal ArrowArrayStream — it does **not**
propagate into the underlying Python generator and does **not** stop the
server-side ODBC cursor. This wrapper closes that gap.

Interoperability: this class exposes ``__arrow_c_stream__`` (Arrow
PyCapsule Protocol, pyarrow >= 14), so Arrow-aware consumers
(``pyarrow.RecordBatchReader.from_stream``, ``polars.from_arrow``,
``duckdb.from_arrow``, etc.) can accept it directly without any
``isinstance(x, pyarrow.RecordBatchReader)`` check. Subclassing
``pyarrow.RecordBatchReader`` (a Cython extension type) isn't a viable
alternative because its ``from_batches`` factory returns the base class
regardless of the subclass, ``__class__`` reassignment is rejected on
Cython types, and instances cannot hold arbitrary Python attributes —
so a subclass could not carry the cursor/generator refs this wrapper
needs for cancellation semantics.

Design (optimized):
* The Python generator backing the reader carries its own ``try/finally``
block — so server-side cleanup runs symmetrically whether the user
exhausts the reader, calls ``close()`` mid-iteration, exits a ``with``
block, or just lets the reader be garbage-collected. ``close()``
itself only has to (a) call ``SQLCancel`` to unblock any fetch in
flight on another thread and (b) close the generator; the
``finally`` clause does the rest.
* ``SQLCancel`` is called *before* ``SQLFreeStmt(SQL_CLOSE)`` so a fetch
running on another thread returns cleanly first. ``SQLCancel`` is
the single ODBC entry point (with the diag-record functions) that the
spec marks as safe to call from a different thread than the one
owning the statement.
* Diagnostics are drained *before* the cursor is closed, so records
produced by a cancelled fetch are not lost; a second drain after
close picks up anything ``SQL_CLOSE`` itself emits.
* Cached ``pyarrow.ArrowInvalid`` avoids per-read imports on the
post-close error path.
* ``__del__`` is guarded against interpreter finalization.

The parent ``Cursor`` is **not** closed; it remains fully usable.
"""

__slots__ = ("_cursor", "_inner", "_generator", "_closed", "_arrow_invalid")

def __init__(
self,
cursor: "Cursor",
inner: "pyarrow.RecordBatchReader",
generator,
arrow_invalid_exc: type,
) -> None:
self._cursor = cursor
self._inner = inner
self._generator = generator
self._closed = False
# Cache the exception class so post-close reads in a hot loop don't
# re-import pyarrow.
self._arrow_invalid = arrow_invalid_exc

# ── Public surface mirroring pyarrow.RecordBatchReader ────────────────

@property
def closed(self) -> bool:
"""True once ``close()`` has been called."""
return self._closed

@property
def schema(self):
"""Schema of the record batches produced by this reader."""
if self._closed:
raise self._arrow_invalid("Reader is closed")
return self._inner.schema

def read_next_batch(self):
if self._closed:
raise self._arrow_invalid("Reader is closed")
return self._inner.read_next_batch()

def __arrow_c_stream__(self, requested_schema=None):
"""Arrow PyCapsule Protocol — export as an Arrow C stream.

Implements the Arrow PyCapsule Protocol for streams (pyarrow >= 14),
so this wrapper can be consumed by any Arrow-compatible library
(``pyarrow.RecordBatchReader.from_stream``, ``polars.from_arrow``,
``duckdb.from_arrow``, ``pandas.api.interchange.from_dataframe`` for
streams, etc.) without an ``isinstance(x, pa.RecordBatchReader)``
check. See
https://arrow.apache.org/docs/format/CDataInterface/PyCapsuleInterface.html

Note: once the capsule has been consumed by the caller, the
underlying pyarrow reader's internal C stream is transferred out;
further calls to ``read_next_batch()`` on this wrapper will fail
with ``ArrowInvalid``. That mirrors pyarrow's own semantics.
"""
if self._closed:
raise self._arrow_invalid("Reader is closed")
# Delegate to the inner pyarrow reader. pyarrow >= 14 exposes
# ``__arrow_c_stream__`` directly on ``RecordBatchReader``; older
# versions do not implement the protocol. Fail explicitly rather
# than silently returning something invalid.
inner_export = getattr(self._inner, "__arrow_c_stream__", None)
if inner_export is None:
raise self._arrow_invalid(
"Arrow PyCapsule Protocol requires pyarrow>=14; "
"the installed pyarrow version does not expose "
"RecordBatchReader.__arrow_c_stream__."
)
return inner_export(requested_schema)

def __iter__(self):
return self

def __next__(self):
if self._closed:
raise self._arrow_invalid("Reader is closed")
return self._inner.read_next_batch()

def __enter__(self):
if self._closed:
raise self._arrow_invalid("Reader is closed")
return self

def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
return False

def __del__(self):
# Best-effort cleanup if the user never called close() (or a previous
# close() attempt failed to release the generator and left cleanup
# incomplete) and the reader is being garbage-collected. Skip during
# interpreter shutdown — the module globals (pyarrow, ddbc_bindings)
# may already be torn down, and touching native code at that point is
# unsafe.
try:
import sys as _sys

if _sys.is_finalizing():
return
# Retry whenever the generator is still referenced — covers both
# "user never called close()" and "earlier close() raised before
# the generator was released".
if getattr(self, "_generator", None) is not None:
self.close()
except Exception: # pylint: disable=broad-exception-caught
pass

# ── Close implementation ──────────────────────────────────────────────

def close(self) -> None:
"""Synchronously stop fetching, release the server-side cursor, and
reset parent-cursor bookkeeping. Idempotent **and retry-safe**:
if a previous call raised before the generator was released (for
example because another thread was still executing it and
``generator.close()`` raised ``ValueError: generator already
executing``), subsequent calls will pick up where the failed call
left off rather than silently no-op'ing.

Most of the actual cleanup work lives in the generator's ``finally``
clause (see ``Cursor.arrow_reader``); this method just unblocks any
in-flight fetch and closes the generator, which triggers that
``finally`` block.
"""
# Fast path: cleanup already completed on a previous call. We use
# the *generator* reference — not ``_closed`` — as the completion
# marker, because ``_closed`` is flipped early (so racing reads
# raise) and must not by itself disable retry of failed cleanup.
if self._generator is None and self._cursor is None:
self._closed = True
return

# Mark closed first so any racing read raises immediately, even if
# the cleanup steps below fail and we end up retried later.
self._closed = True

# SQLCancel (cross-thread safe) — unblocks a fetch running on another
# thread so that the generator's finally clause can then run
# SQLFreeStmt(SQL_CLOSE) without risking the undefined-behaviour
# window of closing an HSTMT mid-fetch. Safe no-op for an idle stmt.
cursor = self._cursor
if cursor is not None and not cursor.closed and cursor.hstmt is not None:
try:
cursor.hstmt._cancel() # pylint: disable=protected-access
except Exception as e: # pylint: disable=broad-exception-caught
logger.debug("arrow_reader.close: SQLCancel raised: %s", e)

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.

In the cleanup path, each step is wrapped in a broad except ... logger.debug(...). That's fine for the diagnostic-drain steps - if those fail we just lose some warning text.

But _close_cursor() is different: if it fails, the server-side cursor (and its locks) leak on SQL Server, and over time that can cause real production issues. Right now that failure is logged at DEBUG, which is typically disabled in production (INFO/WARN), so the leak would be completely invisible - there'd be no signal that anything went wrong.

I suggest we raise just the _close_cursor() failure log to WARNING (a failed cursor release is operationally significant and should show up in normal logs). The diagnostic-drain failures can stay at DEBUG.


# Close the generator — this raises GeneratorExit inside it, which
# runs the try/finally cleanup block (SQLFreeStmt + diag drain +
# cursor bookkeeping reset). If close() raises and the generator is
# still alive (e.g. another thread is currently executing it), keep
# the reference so a subsequent close() / __del__ can retry; only
# drop refs once the generator is actually dead.
gen = self._generator
if gen is not None:
try:
gen.close()
except Exception as e: # pylint: disable=broad-exception-caught
logger.debug("arrow_reader.close: generator.close raised: %s", e)
if getattr(gen, "gi_frame", None) is not None:
# Generator still alive — leave _generator (and _cursor,
# so the next retry can re-issue SQLCancel) intact.
return
self._generator = None

# Drop strong refs so the wrapper does not extend the lifetime of
# the parent Cursor or the inner pyarrow reader.
self._cursor = None
self._inner = None


class Cursor: # pylint: disable=too-many-instance-attributes,too-many-public-methods
"""
Represents a database cursor, which is used to manage the context of a fetch operation.
Expand Down Expand Up @@ -2734,15 +2945,28 @@ def arrow(self, batch_size: int = 8192) -> "pyarrow.Table":

def arrow_reader(self, batch_size: int = 8192) -> "pyarrow.RecordBatchReader":
"""
Fetch the result as a pyarrow RecordBatchReader, which yields Record
Batches of the specified size until the current result set is
exhausted.
Fetch the result as a pyarrow-compatible RecordBatchReader, which
yields Record Batches of the specified size until the current result
set is exhausted.

The returned object behaves like ``pyarrow.RecordBatchReader``
(``schema``, ``read_next_batch``, iteration, context manager) but
its ``close()`` is fully effective. Cleanup is driven
by a ``try/finally`` block inside the underlying batch generator, so
the same teardown — ``SQLCancel`` to unblock any in-flight fetch on
another thread, ``SQLFreeStmt(SQL_CLOSE)`` to release the server-side
cursor and locks, draining diagnostics into ``cursor.messages``, and
resetting the parent ``Cursor``'s rownumber / ``rowcount`` state —
runs whether the user (a) exhausts the reader normally, (b) calls
``close()`` mid-iteration, (c) exits a ``with`` block, or (d) just
lets the reader be garbage-collected. The parent ``Cursor`` itself
is **not** closed and can be re-executed. ``close()`` is idempotent.

Args:
batch_size: Size of the Record Batches produced by the reader.

Returns:
A pyarrow RecordBatchReader for the result set.
A pyarrow-compatible RecordBatchReader for the result set.
"""
self._check_closed() # Check if the cursor is closed
pyarrow = self._ensure_pyarrow()
Expand All @@ -2751,11 +2975,61 @@ def arrow_reader(self, batch_size: int = 8192) -> "pyarrow.RecordBatchReader":
schema_batch = self.arrow_batch(0)
schema = schema_batch.schema

# Capture the parent cursor in a closure cell that the generator
# can null out after cleanup, so a GC'd reader does not keep the
# cursor pinned.
cursor_ref = [self]

def batch_generator():
while (batch := self.arrow_batch(batch_size)).num_rows > 0:
yield batch
try:
while (batch := cursor_ref[0].arrow_batch(batch_size)).num_rows > 0:
yield batch
finally:
# Symmetric server-side teardown — runs on exhaustion,
# GeneratorExit (from close()), or an exception inside the
# body. This is the single canonical cleanup site.
cur = cursor_ref[0]
cursor_ref[0] = None
if cur is None or cur.closed or cur.hstmt is None:
return

return pyarrow.RecordBatchReader.from_batches(schema, batch_generator())
# 1) Drain diagnostics produced by the (possibly cancelled)
# fetch *before* SQL_CLOSE so we don't lose them.
try:
cur.messages.extend(ddbc_bindings.DDBCSQLGetAllDiagRecords(cur.hstmt))
except Exception as e: # pylint: disable=broad-exception-caught
logger.debug("arrow_reader cleanup: pre-close diag drain failed: %s", e)

# 2) Release the server-side cursor & locks while keeping the
# HSTMT and prepared plan intact, so the parent Cursor can
# be re-executed.
try:
cur.hstmt._close_cursor() # pylint: disable=protected-access
except Exception as e: # pylint: disable=broad-exception-caught
logger.debug("arrow_reader cleanup: _close_cursor failed: %s", e)

# 3) Drain diagnostics produced by SQL_CLOSE itself. This
# runs unconditionally because SQL_CLOSE can return
# SQL_SUCCESS_WITH_INFO (a *success* code) and still leave
# warning records on the HSTMT diag stack; the previous
# "only on failure" path would silently drop those.
try:
cur.messages.extend(ddbc_bindings.DDBCSQLGetAllDiagRecords(cur.hstmt))
except Exception as e: # pylint: disable=broad-exception-caught
logger.debug("arrow_reader cleanup: post-close diag drain failed: %s", e)

# 4) Reset cursor bookkeeping to a clean "no result set"
# state. rowcount becomes -1 to signal that the prior
# result is no longer meaningful.
try:
cur._clear_rownumber() # pylint: disable=protected-access
cur.rowcount = -1
except Exception as e: # pylint: disable=broad-exception-caught
logger.debug("arrow_reader cleanup: bookkeeping reset failed: %s", e)

gen = batch_generator()
inner = pyarrow.RecordBatchReader.from_batches(schema, gen)
return _ArrowReader(self, inner, gen, pyarrow.ArrowInvalid)

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.

_ArrowReader has no internal lock, so two threads calling close() concurrently (or close() racing with __del__) can both pass the self._generator is not Noneguard and both callgen.close()/_cancel(). The individual native calls are guarded/idempotent so this is unlikely to corrupt state, but do you think we need to consider a lightweight threading.Lock` around the close body to make the documented cross-thread semantics fully robust?

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.

Previously arrow_reader() returned a real pyarrow.RecordBatchReader; it now returns _ArrowReader, which (for good reasons documented in the code) does not subclass pyarrow.RecordBatchReader. This is a genuine behavioral change and can silently break existing users in two ways:

  • isinstance checks fail: Any consumer doing isinstance(reader, pyarrow.RecordBatchReader) will now get False. The new __arrow_c_stream__ protocol nicely covers polars/duckdb/from_stream, but a direct isinstance check has no way to benefit from it.

  • The type hint is now inaccurate: it still advertises -> "pyarrow.RecordBatchReader" while returning _ArrowReader, which misleads users and type checkers. Pls check above.

So,here is that I was thinking as suggestions:

  1. Update the return annotation to reflect reality
    def arrow_reader(self, batch_size: int = 8192) -> "_ArrowReader":
  2. Add a changelog entry and a line in the docstring explicitly calling out: "this is a pyarrow-compatible reader, not a pyarrow.RecordBatchReader; consume it via the Arrow C-stream protocol rather than an isinstance check."
    Please confirm no internal callers rely on the exact pyarrow.RecordBatchReader type.
    Keeping the wrapper is the right call - this is just about making the contract change visible so users aren't caught off guard.


def nextset(self) -> Optional[bool]:
"""
Expand Down
32 changes: 31 additions & 1 deletion mssql_python/pybind/ddbc_bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ SQLEndTranFunc SQLEndTran_ptr = nullptr;
SQLFreeHandleFunc SQLFreeHandle_ptr = nullptr;
SQLDisconnectFunc SQLDisconnect_ptr = nullptr;
SQLFreeStmtFunc SQLFreeStmt_ptr = nullptr;
SQLCancelFunc SQLCancel_ptr = nullptr;

// Diagnostic APIs
SQLGetDiagRecFunc SQLGetDiagRec_ptr = nullptr;
Expand Down Expand Up @@ -1445,6 +1446,7 @@ DriverHandle LoadDriverOrThrowException() {
SQLDisconnect_ptr = GetFunctionPointer<SQLDisconnectFunc>(handle, "SQLDisconnect");
SQLFreeHandle_ptr = GetFunctionPointer<SQLFreeHandleFunc>(handle, "SQLFreeHandle");
SQLFreeStmt_ptr = GetFunctionPointer<SQLFreeStmtFunc>(handle, "SQLFreeStmt");
SQLCancel_ptr = GetFunctionPointer<SQLCancelFunc>(handle, "SQLCancel");

SQLGetDiagRec_ptr = GetFunctionPointer<SQLGetDiagRecFunc>(handle, "SQLGetDiagRecW");

Expand Down Expand Up @@ -1606,6 +1608,31 @@ void SqlHandle::close_cursor() {
}
}

void SqlHandle::cancel() {

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.

We are allowing cancel() to be called from a different thread than the one owning the statement (that's the cross-thread close() feature). But SqlHandle itself has no mutex.

Because of that, cancel() checks _handle / _implicitly_freed and then calls SQLCancel_ptr(h) with no lock. If another thread runs free() / close_cursor() in between, we can hit a use-after-free (calling SQLCancel on a handle that was just released), which may crash.

Since this PR is what makes concurrent access possible, could we protect the check + SQLCancel call with a lock, and have free() / close_cursor() take the same lock?

Alternatively, if we're confident free() can never race with cancel() in practice, a short comment explaining why it's safe would resolve the concern.

// SQLCancel is intentionally lenient: it is a no-op on non-STMT handles,
// already-freed handles, or if the driver does not expose it. This lets
// _ArrowReader.close() call it unconditionally without coordinating with
// the fetch thread. The GIL is released so a blocked fetch thread can
// observe the cancel and return.
if (_type != SQL_HANDLE_STMT || !_handle || _implicitly_freed) {
return;
}
if (!SQLCancel_ptr) {
return;
}
SQLHANDLE h = _handle;
SQLRETURN ret;
{
py::gil_scoped_release release;
ret = SQLCancel_ptr(h);
}
// SQLCancel may return SQL_SUCCESS_WITH_INFO when there was nothing to
// cancel; that is fine. We only throw on hard failure.
if (ret != SQL_SUCCESS && ret != SQL_SUCCESS_WITH_INFO) {
ThrowStdException("SQLCancel failed");
}
}

SQLRETURN SQLResetStmt_wrap(SqlHandlePtr statementHandle) {
if (!statementHandle || !statementHandle->get()) {
return SQL_INVALID_HANDLE;
Expand Down Expand Up @@ -6009,7 +6036,10 @@ PYBIND11_MODULE(ddbc_bindings, m) {
py::class_<SqlHandle, SqlHandlePtr>(m, "SqlHandle")
.def("free", &SqlHandle::free, "Free the handle")
.def("_close_cursor", &SqlHandle::close_cursor,
"Internal: close the cursor without freeing the prepared statement");
"Internal: close the cursor without freeing the prepared statement")
.def("_cancel", &SqlHandle::cancel,
"Internal: cancel an in-progress statement (SQLCancel). "
"Safe to call from another thread; no-op if unsupported or idle.");

py::class_<ConnectionHandle>(m, "Connection")
.def(py::init<const std::u16string&, bool, const py::dict&, const std::u16string&,
Expand Down
Loading
Loading