-
Notifications
You must be signed in to change notification settings - Fork 52
FIX: Implementing RecordBatchReader.Close() functionality for Arrow #644
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a5190b7
4435791
7566883
d7f5df4
873b58f
0ec3c83
601ad30
c2c6d03
27ca8ad
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
|
||
| # 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. | ||
|
|
@@ -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() | ||
|
|
@@ -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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Previously
So,here is that I was thinking as suggestions:
|
||
|
|
||
| def nextset(self) -> Optional[bool]: | ||
| """ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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"); | ||
|
|
||
|
|
@@ -1606,6 +1608,31 @@ void SqlHandle::close_cursor() { | |
| } | ||
| } | ||
|
|
||
| void SqlHandle::cancel() { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We are allowing Because of that, Since this PR is what makes concurrent access possible, could we protect the check + Alternatively, if we're confident |
||
| // 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; | ||
|
|
@@ -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&, | ||
|
|
||
There was a problem hiding this comment.
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 toWARNING(a failed cursor release is operationally significant and should show up in normal logs). The diagnostic-drain failures can stay atDEBUG.