Skip to content
Draft
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
4 changes: 2 additions & 2 deletions mssql_python/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1032,13 +1032,13 @@ def _reset_cursor(self) -> None:
self.is_stmt_prepared = [False]

def _soft_reset_cursor(self) -> None:
"""Lightweight reset: close cursor and unbind params without freeing the HSTMT.
"""Close results without freeing the HSTMT or compatible cached bindings.

Preserves the prepared statement plan on the server so repeated
executions of the same SQL skip SQLPrepare entirely.
"""
if self.hstmt:
ret = ddbc_bindings.DDBCSQLResetStmt(self.hstmt)
ret = ddbc_bindings.DDBCSQLResetStmt(self.hstmt, preserve_bindings=True)
try:
check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret)
except Exception:
Expand Down
21 changes: 21 additions & 0 deletions mssql_python/pybind/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,27 @@

This README provides instructions to build the DDBC Bindings for your system and documents the platform-specific dependencies.

## Repeated execute bindings

Each statement owns at most one reusable generation of native input buffers.
The existing detector and binder still validate and convert every execution.
Bindings are reused only for the same prepared SQL, parameter count, C/SQL
types, column size, scale, direction, encoding, and actual buffer byte lengths.
Inline text/binary and integer, boolean, and floating-point buffers are supported,
up to 2,100 parameters and 8,000 bytes per text/binary buffer. NULL, DAE, and
complex C types use the uncached path; decimal overrides converted to text
can reuse only with matching precision and scale.

Soft cursor resets preserve successful cached bindings. New SQL, incompatible
metadata or byte lengths, explicit resets, direct/catalog/array execution,
statement-attribute changes, and errors invalidate reuse. Native storage remains
owned until ODBC resets the bindings or frees the statement, including error
paths and parent connection teardown. No Python references are retained in the
cache, and the existing DB-API `threadsafety=1` contract is unchanged.

`tests/test_037_cached_bindings.py` checks live round trips and actual native
allocation/bind events through the existing debug logger, without a test-only API.

## **Key Architecture Handling**

1. **Architecture Normalization** (from `mssql_python/ddbc_bindings.py`):
Expand Down
29 changes: 19 additions & 10 deletions mssql_python/pybind/connection/connection.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -119,14 +119,13 @@ void Connection::disconnect() {
LOG("Disconnecting from database");
}

// CRITICAL FIX: Mark all child statement handles as implicitly freed
// When we free the DBC handle below, the ODBC driver will automatically free
// all child STMT handles. We need to tell the SqlHandle objects about this
// so they don't try to free the handles again during their destruction.

// Retain child owners and bound buffers through SQLDisconnect. With the
// GIL held, checkError throws on failure before retiring any handles.

// THREAD-SAFETY: Lock mutex to safely access _childStatementHandles
// This protects against concurrent allocStatementHandle() calls or GC finalizers
size_t originalSize = 0, afterCompactSize = 0, badHandleCount = 0;
std::vector<SqlHandlePtr> childHandles;
{
std::lock_guard<std::mutex> lock(_childHandlesMutex);

Expand All @@ -147,11 +146,9 @@ void Connection::disconnect() {
++badHandleCount;
continue; // Skip marking to prevent leak
}
handle->markImplicitlyFreed();
childHandles.push_back(std::move(handle));
}
}
_childStatementHandles.clear();
_allocationsSinceCompaction = 0;
} // Release lock before potentially slow SQLDisconnect call

// Log after releasing _childHandlesMutex (#671): LOG()/LOG_ERROR() acquire
Expand All @@ -178,15 +175,27 @@ void Connection::disconnect() {
// Destructor / shutdown path — GIL is not held, call directly.
ret = SQLDisconnect_ptr(_dbcHandle->get());
}
// In destructor/shutdown paths, suppress errors to avoid
// std::terminate() if this throws during stack unwinding.
// Surface errors with the GIL held. GIL-less teardown cannot safely
// translate errors through Python, so it continues retiring the handles.
if (hasGil) {
checkError(ret);
} else if (!SQL_SUCCEEDED(ret)) {
// Intentionally no LOG() here: LOG() acquires the GIL internally
// via py::gil_scoped_acquire, which is unsafe during interpreter
// shutdown or stack unwinding (can deadlock or call std::terminate).
}
// Successful SQLDisconnect has already freed its child statements.
// GIL-less failure also retires these wrappers as the parent is abandoned;
// neither that failure nor dropping the DBC owner proves native deallocation.
for (const auto& handle : childHandles) {
handle->markImplicitlyFreed();
handle->releaseAfterFree();
}
{
std::lock_guard<std::mutex> lock(_childHandlesMutex);
_childStatementHandles.clear();
_allocationsSinceCompaction = 0;
}
// triggers SQLFreeHandle via destructor, if last owner
_dbcHandle.reset();
} else if (hasGil) {
Expand Down
Loading
Loading