Skip to content

PERF: Reuse native buffers and bindings across fetchmany calls - #806

Open
Jahnvi Thakkar (jahnvi480) wants to merge 24 commits into
mainfrom
jahnvi/perf-fetch-buffer-reuse
Open

Jahnvi Thakkar (jahnvi480) wants to merge 24 commits into
mainfrom
jahnvi/perf-fetch-buffer-reuse

Conversation

@jahnvi480

@jahnvi480 Jahnvi Thakkar (jahnvi480) commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Work Item / Issue Reference

ADO Task: AB#48352


Summary

Follow-up to #796, now merged into main. #796 reuses result-set metadata; this change reuses one statement-owned native buffer and ODBC binding plan across compatible, bounded-column fetchmany() calls.

Reuse requires the same result generation, metadata, batch size, and decoding settings. Checked cleanup protects driver-held pointers across incompatible transitions, errors, and close. Public APIs/defaults, Row/converter semantics, and MAX/LOB/sql_variant fallback routes remain unchanged; there is no hidden prefetch.

Repository scope (30893611 → 39e22098): five runtime files plus regressions in the existing tests/test_025_profiler.py. There is no effective build/CI or documentation diff. Three isolated cases use the existing profiler to assert actual bind/unbind counts for a compatible hit, a size/encoding/result-set miss, resumed reuse, and EOF. One additional focused test, parameterized for failed unbinding and failed rows-fetched-pointer cleanup, covers failure propagation, blocked reuse/rebinding/fetching, and recovery. These cases require a profiling-enabled native build and live SQL; unavailable profiling is explicitly skipped, not reported as zero calls. The failure-injection cases also skip Windows because its ODBC function-pointer globals are not exported there.

Scope correction: ad0a8550 reverted the four-file, 308-line cleanup-test increment 1c05d11e after the user rejected its size. The native test target, CMake/CI wiring, and related documentation remain removed. ad0a8550 restored the complete be219eb3 source tree; the follow-up through db19e637 added only one 93-line test in the existing test module. 39e22098 parameterizes that same test for the second cleanup-failure boundary (+26/-10 in that file only). There are no additional production, build, CI, or documentation changes. The earlier DevSkim fixes and three profiler regressions remain; history was preserved through a normal revert, not rewritten.

flowchart LR
    subgraph "Before (#796)"
        B["Each fetchmany"] --> S["Allocate + bind"] --> F["Fetch + unbind"]
    end
    subgraph After
        A["First compatible call"] --> P["Owned buffers + bindings"]
        P --> R["Reuse on subsequent fetchmany calls"]
        R --> D["Detach at EOF / transition"]
    end
Loading

Mechanism and correctness

Actual bind/unbind/attribute-call timers and plan-allocation profiling are preserved. The auxiliary value-buffer-growth timer was removed; its absence is not zero allocation work.

The source preserves the main merges through 30893611 and the two DevSkim fixes in 9eb586a6, which replace formatted C calls with native-only output. The reported cache/cleanup lock inversion was checked against the function-local mutex lifetimes: cache clearing returns and releases its lock before the cleanup gate is acquired; no teardown reordering was needed.

Prior SQL counter validation and source equivalence: Azure DevOps build 178157 checked out merge e3dc03c0, whose complete tree e29a5927 matches both be219eb3 and ad0a8550. Both Ubuntu x86_64 / Python 3.12.3 SQL Server 2022 and SQL Server 2025 legs completed fresh Release ON native builds and the existing checkout/profiling import guard. Each leg passed all three size/encoding/result regression cases, with zero selected skips. Each full pytest task reported 5,557 passed, 132 skipped, 42 deselected. These results remain attributed to that CI run and do not qualify the subsequently added cleanup-failure cases. Successful raw counters and native-binary hashes were not independently exported; this is not full-matrix qualification.

The original 9eb586a6 CI run exposed an unsupported UTF-16 alias in the encoding test before its transition fetch. be219eb3 corrected only two test-input lines to supported ASCII and Latin-1 settings with SQL_CHAR fixed; the original failure is retained in the evidence. This tests decoding-configuration invalidation, not a change in Linux's effective UTF-8 decoder.

Windows OFF: 9eb586a6 passed a fresh Windows x64 / Python 3.13.15 Release OFF build with /W4 /WX, followed by source-package/fresh-native import with profiling absence asserted. The current source has identical native and build inputs; subsequent changes here are test-only. This is explicit source equivalence, not a newly rebuilt Windows binary or execution of the cleanup-failure cases.

Focused cleanup-failure regression — both cases passed in current-source CI: In response to the requested detach/unbind failure-injection coverage, test_fetchmany_failed_cleanup_blocks_reuse_until_cleanup_succeeds runs isolated [unbind] and [rows_fetched_ptr] cases. The first injects SQL_ERROR through the existing SQLFreeStmt_ptr. The second leaves real unbinding untouched and injects SQL_ERROR only when SQLSetStmtAttr_ptr clears SQL_ATTR_ROWS_FETCHED_PTR, exercising failure after successful unbinding. In each case, a resized fetch and a retry at the original size must return failure with no rows, retry cleanup, and leave actual plan-allocation, bind, and fetch counts unchanged. Restoring the pointer must allow recovery with the unconsumed rows and then EOF. The test also rejects terminal-retention diagnostics. This covers the observable native-return/non-reuse/recovery contract, not direct private C++ allocation/destructor proof or public exception translation.

Azure DevOps build 178187 checked out merge 21797ffd, whose complete tree c23883f3 exactly matches published head 39e22098. Both Ubuntu x86_64 / Python 3.12.3 SQL Server 2022 and SQL Server 2025 legs completed fresh Release ON native builds and the existing checkout/profiling import guard. Both exact cleanup-failure cases passed once in each leg, with zero selected skips; the three existing size/encoding/result cases also passed. Each full pytest task reported 5,559 passed, 132 skipped, 42 deselected, 2 warnings. Raw checkout/build/test logs are retained. Successful child counters and native-binary hashes were not independently exported; these results do not claim Windows execution, full-matrix completion, performance gains, or direct destructor proof. Earlier build 178178 qualified only the original single failed-unbind case at db19e637; its evidence remains separate. The withdrawn native target's 1c05d11e Windows results remain historical only and are not evidence for these cases.

The #809 integration captures intermediate retained-binding warnings into native-only per-call storage, then appends them to cursor messages outside the cleanup gate with the GIL held. Checked unbinding and original failing statuses are preserved; SQL_SUCCESS does not trigger diagnostic scans. Runtime warning/error ordering and broad fault-path qualification remain outside these focused tests.

Prior scoped correctness evidence: Linux Release OFF checks of frozen #796 revision 65081f06 and 13ad1165 passed 339 ordinary + 3 isolated tests per arm, zero skips. The same 65081f06 arm was reused for this incremental comparison; removed tests/native fixtures and ON runs were not included. Source-hash differences were confirmed as archive CRLF/LF differences, and the scoped correctness/minimality review closed for those revisions. These results do not qualify the current head.

Historical mechanism evidence only: earlier 416e54d6 / tree 99926c46 qualification included native fixtures, standalone 6 baseline / 9 candidate cases, and fetchmany(1) counts of one plan, 24 actual binds, one unbind, and 10,001 native fetches for 10,000 rows × 24 INT columns. This does not qualify the current head or establish elapsed-time gains. Baseline retained-binding counters were unavailable, not zero.

Original failures, skips, and inapplicable baseline-counter oracles remain in the qualification ledger. This is not a clean full-suite claim; mock ODBC/GIL fault tests are not live-driver concurrency proof.

Latency, no-regression, current-main, and pyodbc acceptance remain open. This is not performance/readiness signoff.

Remove native metadata dictionary roundtrips while preserving eager Unicode names and fresh per-call descriptions. Add behavior and profiling regression coverage. Performance acceptance remains unresolved after the bounded local study.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Preserve reserve-before-locking-weak-handles and release retained handles outside the child-list mutex. Expose the unchanged native-only algorithm for direct invariant coverage.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Require successful scalar NULL handling and exact describe counts. Add production-header cache, failure, concurrency, allocation and lifetime tests with active Release assertions, plus Windows/Linux/macOS CTest CI and test guidance.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Use C++ streams for test-runner output, document the line-scoped allocator rule exception, and trigger native invariant tests for production integration header and binding changes. Keep production fetch code and the allocation-failure probes unchanged.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Preserve the bounded fetch-buffer follow-up on the original #796 dependency.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Align only the reviewed fetch-buffer follow-up with dependency416.
The exact tree matches the qualified99926c46 snapshot; the allocator
translation-unit split is test-only and leaves production unchanged.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@github-actions github-actions Bot added the pr-size: large Substantial code update label Sep 22, 2026
Remove PR-only tests, native test build and CI wiring, and restore the base test guide. Inline the test-only invalidation helper and single-caller metadata setup; stop retaining unused precision/nullability fields while preserving the cache lifetime, generation, Unicode, variant and failure contracts.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Merge the frozen metadata dependency and remove PR-only test/build scaffolding.
Use direct profiled ODBC calls and the dependency child snapshot loop while
preserving retained-buffer ownership and cleanup guards.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Preserve the existing fetch-buffer reuse changes and integrate the merged
metadata optimization plus current main without rewriting branch history.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@jahnvi480
Jahnvi Thakkar (jahnvi480) changed the base branch from jahnvi/perf-small-fetch-native-metadata to main September 24, 2026 13:00
Preserve native fetch diagnostics across retained binding setup and cleanup, without Python work under the cleanup gate.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI lite review requested due to automatic review settings September 24, 2026 15:32
@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

PR Performance Report

✅ No regression detected

No consistent slowdowns detected across all 2 environments.

0 IMPROVEMENTS 0 SLOWDOWNS 2/2 ENVIRONMENTS

Coverage: 2 of 2 environments completed. Advisory result; does not block merging.

Performance diagnostics

Phase times are inclusive diagnostics and must not be added together. They identify where measured time changed, not why it changed.

Unix / SQL Server 2022

SELECT queries: ddbc::SQLBindColums +0.015 ms; ddbc::SQLExecDirect_wrap +0.010 ms; ddbc::FetchAll_wrap +0.010 ms. Call changes: ddbc::fetch_bindings::SQLBindCol (added, removed, or intermittent).
Fetch-all queries: ddbc::FetchAll_wrap +4.749 ms; ddbc::FetchBatchData +4.747 ms; py::fetchall::cpp_call +4.163 ms. Call changes: ddbc::fetch_bindings::SQLBindCol (added, removed, or intermittent).
Row-by-row fetching: py::fetchone::cpp_call +0.277 ms; ddbc::FetchOne_wrap +0.228 ms; ddbc::SQLGetData_wrap +0.167 ms. Call changes: ddbc::fetch_bindings::SQL_UNBIND (added, removed, or intermittent).
Batched row fetching: ddbc::FetchBatchData::SQLFetchScroll_call +1.371 ms; ddbc::FetchBatchData +1.326 ms; py::fetchmany::cpp_call +0.687 ms. Call changes: ddbc::SQLBindColums (51 -> 1 calls); ddbc::fetch_bindings::SQLBindCol (added, removed, or intermittent); ddbc::fetch_bindings::SQLGetStmtAttr (added, removed, or intermittent).
Arrow row fetching: ddbc::SQLBindColums +0.001 ms. Call changes: ddbc::fetch_bindings::SQLBindCol (added, removed, or intermittent).
Row fetching in batches of 100: ddbc::FetchBatchData +0.929 ms; ddbc::FetchBatchData::construct_rows +0.477 ms; ddbc::FetchBatchData::SQLFetchScroll_call +0.460 ms. Call changes: ddbc::SQLBindColums (501 -> 1 calls); ddbc::fetch_bindings::SQLBindCol (added, removed, or intermittent); ddbc::fetch_bindings::SQLGetStmtAttr (added, removed, or intermittent).
Row fetching in batches of 10,000: ddbc::FetchBatchData::construct_rows +2.281 ms; ddbc::FetchMany_wrap +2.156 ms; py::fetchmany::cpp_call +2.036 ms. Call changes: ddbc::SQLBindColums (6 -> 1 calls); ddbc::fetch_bindings::SQLBindCol (added, removed, or intermittent); ddbc::fetch_bindings::SQLGetStmtAttr (added, removed, or intermittent).
Repeated positional queries: py::execute::cpp_call +0.280 ms; ddbc::SQLExecute_wrap +0.247 ms; ddbc::SQLRowCount_wrap +0.001 ms. Call changes: ddbc::fetch_bindings::SQL_UNBIND (added, removed, or intermittent).
Repeated named-parameter queries: ddbc::FetchOne_wrap +0.031 ms; py::fetchone::cpp_call +0.027 ms; ddbc::SQLGetData_wrap +0.025 ms. Call changes: ddbc::fetch_bindings::SQL_UNBIND (added, removed, or intermittent).
Joined aggregation queries: py::execute::cpp_call +1.129 ms; ddbc::SQLExecDirect_wrap +1.125 ms; ddbc::SQLBindColums +0.007 ms. Call changes: ddbc::fetch_bindings::SQLBindCol (added, removed, or intermittent).
Large joined-result fetching: py::fetchall::row_wrap +0.420 ms; ddbc::FetchBatchData::cache_column_metadata +0.009 ms; ddbc::SQLBindColums +0.004 ms. Call changes: ddbc::fetch_bindings::SQLBindCol (added, removed, or intermittent).
1.2-million-row fetching: py::fetchall::cpp_call +48.642 ms; ddbc::FetchBatchData +45.500 ms; ddbc::FetchAll_wrap +45.483 ms. Call changes: ddbc::fetch_bindings::SQLBindCol (added, removed, or intermittent).
Common table expression queries: ddbc::FetchAll_wrap +0.043 ms; py::fetchall::cpp_call +0.043 ms; ddbc::FetchBatchData +0.043 ms. Call changes: ddbc::fetch_bindings::SQLBindCol (added, removed, or intermittent).

Unix / SQL Server 2025

SELECT queries: ddbc::SQLBindColums +0.008 ms; ddbc::FetchBatchData::construct_rows +0.004 ms; py::fetchall::row_wrap +0.001 ms. Call changes: ddbc::fetch_bindings::SQLBindCol (added, removed, or intermittent).
Fetch-all queries: py::fetchall::cpp_call +2.340 ms; ddbc::FetchBatchData +2.181 ms; ddbc::FetchAll_wrap +2.169 ms. Call changes: ddbc::fetch_bindings::SQLBindCol (added, removed, or intermittent).
Row-by-row fetching: py::fetchone::cpp_call +0.215 ms; ddbc::FetchOne_wrap +0.122 ms; ddbc::SQLGetData_wrap +0.011 ms. Call changes: ddbc::fetch_bindings::SQL_UNBIND (added, removed, or intermittent).
Batched row fetching: py::fetchmany::cpp_call +3.910 ms; ddbc::FetchBatchData +3.124 ms; ddbc::FetchBatchData::construct_rows +2.127 ms. Call changes: ddbc::SQLBindColums (51 -> 1 calls); ddbc::fetch_bindings::SQLBindCol (added, removed, or intermittent); ddbc::fetch_bindings::SQLGetStmtAttr (added, removed, or intermittent).
Arrow row fetching: ddbc::SQLBindColums +0.002 ms; ddbc::SQLDescribeCol::driver_call +0.000 ms. Call changes: ddbc::fetch_bindings::SQLBindCol (added, removed, or intermittent).
Row fetching in batches of 100: ddbc::FetchBatchData +1.039 ms; ddbc::FetchBatchData::construct_rows +0.898 ms; ddbc::SQLDescribeCol::driver_call +0.000 ms. Call changes: ddbc::SQLBindColums (501 -> 1 calls); ddbc::fetch_bindings::SQLBindCol (added, removed, or intermittent); ddbc::fetch_bindings::SQLGetStmtAttr (added, removed, or intermittent).
Row fetching in batches of 10,000: ddbc::FetchBatchData +2.347 ms; ddbc::FetchBatchData::construct_rows +2.015 ms; py::fetchmany::cpp_call +0.832 ms. Call changes: ddbc::SQLBindColums (6 -> 1 calls); ddbc::fetch_bindings::SQLBindCol (added, removed, or intermittent); ddbc::fetch_bindings::SQLGetStmtAttr (added, removed, or intermittent).

6 additional diagnostic rows are available in the raw ADO artifacts.

All database tasks and timings

Unix / SQL Server 2022

Database task Before After Paired change Result
Connection opening 10.536 ms 10.370 ms -0.6% no signal
SELECT queries 1.099 ms 1.105 ms +4.0% no signal
Row insertion 34.809 ms 34.853 ms +0.1% no signal
Executemany inserts 156.401 ms 162.949 ms +1.5% no signal
Fetch-all queries 123.759 ms 127.310 ms +0.3% no signal
Row-by-row fetching 14.431 ms 14.622 ms +1.2% no signal
Batched row fetching 117.296 ms 118.055 ms +0.5% no signal
Transaction commit and rollback 116.497 ms 115.481 ms -0.3% no signal
Arrow row fetching 95.029 ms 94.783 ms -0.9% no signal
100,000-row insertion 448.652 ms 444.843 ms +0.3% no signal
Row fetching in batches of 100 122.500 ms 119.642 ms -1.3% no signal
Row fetching in batches of 10,000 126.564 ms 128.946 ms -3.2% no signal
Repeated positional queries 34.189 ms 34.163 ms -0.1% no signal
Repeated named-parameter queries 38.799 ms 36.873 ms -2.8% no signal
Legacy 100,000-row insertion 352.786 ms 354.677 ms +0.5% no signal
Insertion with explicit input sizes 494.384 ms 507.645 ms +2.7% no signal
Joined aggregation queries 180.378 ms 181.573 ms +0.1% no signal
Large joined-result fetching 188.092 ms 187.801 ms -0.2% no signal
1.2-million-row fetching 3460.491 ms 3490.607 ms +0.7% no signal
Common table expression queries 5.371 ms 5.364 ms -0.1% no signal
256 KiB VARCHAR(MAX) / fetchall() 1.261 ms 1.311 ms +3.9% no signal

Unix / SQL Server 2025

Database task Before After Paired change Result
Connection opening 97.443 ms 97.284 ms -0.6% no signal
SELECT queries 1.067 ms 1.070 ms +2.5% no signal
Row insertion 34.205 ms 34.313 ms +0.6% no signal
Executemany inserts 148.386 ms 151.592 ms +2.0% no signal
Fetch-all queries 120.618 ms 125.918 ms +0.7% no signal
Row-by-row fetching 14.559 ms 14.764 ms -0.7% no signal
Batched row fetching 117.211 ms 121.877 ms +4.0% no signal
Transaction commit and rollback 115.926 ms 115.567 ms -0.1% no signal
Arrow row fetching 93.994 ms 94.802 ms -0.1% no signal
100,000-row insertion 429.775 ms 433.505 ms -1.3% no signal
Row fetching in batches of 100 122.370 ms 119.441 ms -2.4% no signal
Row fetching in batches of 10,000 128.031 ms 128.679 ms +1.4% no signal
Repeated positional queries 33.923 ms 33.620 ms -0.2% no signal
Repeated named-parameter queries 35.909 ms 35.824 ms -0.9% no signal
Legacy 100,000-row insertion 351.454 ms 347.324 ms -0.5% no signal
Insertion with explicit input sizes 495.150 ms 488.924 ms +0.0% no signal
Joined aggregation queries 158.942 ms 158.454 ms +0.3% no signal
Large joined-result fetching 184.221 ms 185.562 ms +2.6% no signal
1.2-million-row fetching 3532.120 ms 3475.414 ms -0.4% no signal
Common table expression queries 5.163 ms 5.100 ms +0.1% no signal
256 KiB VARCHAR(MAX) / fetchall() 1.473 ms 1.429 ms -4.1% no signal
Build and measurement details

ADO build 178187

PR head: 39e220980e4eac745bb023255e8ae76d12a4a9b4
Base: 30893611a5858942b4a5c8576e433e8b2a3913a7
Measured merge: 21797ffd0d1e834122b2582c6c6d6e9c332ff6d2

  • Unix / SQL Server 2022: Python 3.12.3, x86_64, SQL 16.0.4295.3; 5 paired comparisons and 1 warmup.
  • Unix / SQL Server 2025: Python 3.12.3, x86_64, SQL 17.0.5005.3; 5 paired comparisons and 1 warmup.

A consistent change requires more than 20% median paired movement, at least 1 ms between the median runtimes, and at least 80% of pairs exceeding the relative threshold in the same direction. A slowdown without enough pair agreement is reported as inconsistent.

The displayed change is the median of paired before-and-after ratios. It is not recalculated from the two displayed median runtimes.

Both revisions use profiling-enabled builds on the same agent and database, with alternating order and discarded warmups. Results are diagnostic and do not represent production-wheel latency.

Raw samples and logs are attached to the ADO run as profiler-* artifacts.

Comment thread mssql_python/pybind/ddbc_bindings.cpp Fixed
Comment thread mssql_python/pybind/ddbc_bindings.cpp Fixed

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

A potential teardown deadlock, unresolved cleanup-leak behavior, and missing regression coverage block approval.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity · 1 Medium severity

Open (2)
What changed in this PR

Adds statement-owned native buffers and ODBC binding-plan reuse for compatible fetchmany() calls, with lifecycle cleanup and invalidation.

Changes:

  • Reuses buffers and bindings across compatible batches.
  • Handles transitions, fallback paths, and connection cleanup.
  • Adds binding lifecycle and ownership management.
File Summary
mssql_python/​pybind/​fetch_bindings.hpp Defines reusable buffers, binding plans, and ownership logic; terminal cleanup leaks remain unresolved.
mssql_python/​pybind/​ddbc_bindings.h Integrates fetch-binding state into statement handles.
mssql_python/​pybind/​ddbc_bindings.cpp Implements reuse and cleanup; contains a potential teardown deadlock and lacks regression coverage for reuse transitions.
mssql_python/​pybind/​connection/​connection.h Extends metadata cleanup configuration.
mssql_python/​pybind/​connection/​connection.cpp Coordinates fetch-binding cleanup with connection lifecycle operations.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread mssql_python/pybind/ddbc_bindings.cpp
Comment thread mssql_python/pybind/ddbc_bindings.cpp
@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

📊 Code Coverage Report

🔥 Diff Coverage

84%


🎯 Overall Coverage

83%


📈 Total Lines Covered: 9615 out of 11479
📁 Project: mssql-python


Diff Coverage

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

  • mssql_python/pybind/connection/connection.cpp (83.3%): Missing lines 284
  • mssql_python/pybind/ddbc_bindings.cpp (82.4%): Missing lines 1549-1550,1557-1558,1579-1586,1602-1605,1608-1609,1634-1636,1653-1654,1664-1665,1667-1668,1697,1731-1733,1765-1766,1771-1772,1774-1775,1777-1781,1903,2055,2127,2130-2131,2207-2208,3449,4511,4587,4643,5219-5221,5337,5365
  • mssql_python/pybind/ddbc_bindings.h (100%)
  • mssql_python/pybind/fetch_bindings.hpp (90.5%): Missing lines 113-116,152-153,186

Summary

  • Total: 417 lines
  • Missing: 66 lines
  • Coverage: 84%

mssql_python/pybind/connection/connection.cpp

Lines 280-288

  280     // Releasing the last handle can acquire the connection cleanup gate.
  281     // Keep that destruction outside the child-list lock.
  282     for (const auto& handle : handles) {
  283         handle->resultMetadata.clear();
! 284         if (detachFetchBindings && handle->fetchBindings.hasPlan()) {
  285             handle->requireDetachedFetchBindings();
  286         }
  287     }
  288 }

mssql_python/pybind/ddbc_bindings.cpp

Lines 1545-1554

  1545         std::rethrow_exception(m_loadError);
  1546     }
  1547 }
  1548 
! 1549 static void CaptureFetchBindingDiagnostics(SQLHSTMT stmt, SQLRETURN ret,
! 1550                                           FetchBindingDiagnostics* diagnostics);
  1551 static void AppendFetchBindingDiagnostics(py::handle messages,
  1552                                          const FetchBindingDiagnostics& diagnostics,
  1553                                          bool preserveFailure = false);

Lines 1553-1562

  1553                                          bool preserveFailure = false);
  1554 
  1555 SQLRETURN FetchBindingPlan::attach(SQLHSTMT stmt, FetchBindingDiagnostics* diagnostics) {
  1556     reusable = false;
! 1557     needsReset = true;
! 1558     SQLRETURN ret;
  1559     {
  1560         PERF_TIMER("fetch_bindings::SQLSetStmtAttr::ROW_ARRAY_SIZE");
  1561         ret = SQLSetStmtAttr_ptr(
  1562             stmt, SQL_ATTR_ROW_ARRAY_SIZE,

Lines 1575-1590

  1575     if (!SQL_SUCCEEDED(ret)) {
  1576         return ret;
  1577     }
  1578     if (activeSize != static_cast<SQLULEN>(fetchSize)) {
! 1579         throw std::runtime_error("ODBC changed the requested fetch row-array size");
! 1580     }
! 1581     driverMayReference = true;
! 1582     {
! 1583         PERF_TIMER("fetch_bindings::SQLSetStmtAttr::ROWS_FETCHED_PTR");
! 1584         ret = SQLSetStmtAttr_ptr(stmt, SQL_ATTR_ROWS_FETCHED_PTR, &rowsFetched, 0);
! 1585     }
! 1586     CaptureFetchBindingDiagnostics(stmt, ret, diagnostics);
  1587     if (!SQL_SUCCEEDED(ret)) {
  1588         return ret;
  1589     }
  1590     for (const auto& column : bindings) {

Lines 1598-1613

  1598             return ret;
  1599         }
  1600     }
  1601     reusable = true;
! 1602     return ret;
! 1603 }
! 1604 
! 1605 SQLRETURN FetchBindingPlan::detach(SQLHSTMT stmt, FetchBindingDiagnostics* diagnostics) {
  1606     reusable = false;
  1607     if (!needsReset) {
! 1608         return SQL_SUCCESS;
! 1609     }
  1610     SQLRETURN ret;
  1611     {
  1612         PERF_TIMER("fetch_bindings::SQL_UNBIND");
  1613         ret = SQLFreeStmt_ptr(stmt, SQL_UNBIND);

Lines 1630-1640

  1630         ret = SQLSetStmtAttr_ptr(stmt, SQL_ATTR_ROW_ARRAY_SIZE, reinterpret_cast<SQLPOINTER>(1), 0);
  1631     }
  1632     CaptureFetchBindingDiagnostics(stmt, ret, diagnostics);
  1633     if (SQL_SUCCEEDED(ret)) {
! 1634         needsReset = false;
! 1635     }
! 1636     return ret;
  1637 }
  1638 
  1639 namespace {

Lines 1649-1658

  1649     std::string message = std::string(operation) + ": " + error.ddbcErrorMsg;
  1650     if (error.sqlState.size() == 5) {
  1651         message = "SQLSTATE:" + error.sqlState + ":" + message;
  1652     }
! 1653     ThrowStdException(message);
! 1654 }
  1655 
  1656 }  // namespace
  1657 
  1658 // SqlHandle definition

Lines 1660-1672

  1660                      std::shared_ptr<ConnectionCleanupState> cleanupState)
  1661     : _type(type), _handle(rawHandle), _cleanupState(std::move(cleanupState)) {}
  1662 
  1663 SqlHandle::~SqlHandle() {
! 1664     try {
! 1665         if (_handle) {
  1666             SQLRETURN ret = freeHandle();
! 1667             if (!SQL_SUCCEEDED(ret)) {
! 1668                 // A failed free leaves a live handle. Detach if possible before
  1669                 // the last plan owner applies its native-only emergency policy.
  1670                 SQLRETURN detached = detachFetchBindings();
  1671                 std::cerr << "mssql-python: native handle cleanup failed (" << ret
  1672                           << "), fetch buffer detach returned " << detached << '\n';

Lines 1693-1701

  1693 }
  1694 
  1695 void SqlHandle::markImplicitlyFreed() {
  1696     // SAFETY: Only STMT handles should be marked as implicitly freed.
! 1697     // Successful SQLDisconnect frees the connection's child statements.
  1698     // Other handle types (ENV, DBC, DESC) are NOT automatically freed by parents.
  1699     // Calling this on wrong handle types will cause silent handle leaks.
  1700     if (_type != SQL_HANDLE_STMT) {
  1701         // Log error but don't throw - we're likely in cleanup/destructor path

Lines 1727-1737

  1727 
  1728 SQLRETURN SqlHandle::detachFetchBindingsNative(FetchBindingDiagnostics* diagnostics) {
  1729     auto plan = fetchBindings.snapshot();
  1730     if (!plan) {
! 1731         return SQL_SUCCESS;
! 1732     }
! 1733     if (_implicitly_freed || (_cleanupState && _cleanupState->disconnected)) {
  1734         fetchBindings.nativeReleased();
  1735         return SQL_SUCCESS;
  1736     }
  1737     if (!_handle || !SQLFreeStmt_ptr || !SQLSetStmtAttr_ptr) {

Lines 1761-1785

  1761     SQLRETURN ret;
  1762     try {
  1763         if (PyGILState_Check()) {
  1764             py::gil_scoped_release release;
! 1765             ret = detachNative();
! 1766         } else {
  1767             ret = detachNative();
  1768         }
  1769     } catch (...) {
  1770         resultMetadata.clear();
! 1771         AppendFetchBindingDiagnostics(messages, diagnostics, true);
! 1772         throw;
  1773     }
! 1774     if (!SQL_SUCCEEDED(ret)) {
! 1775         resultMetadata.clear();
  1776     }
! 1777     // No Python objects or callbacks while the native cleanup gate is held.
! 1778     AppendFetchBindingDiagnostics(messages, diagnostics, !SQL_SUCCEEDED(ret));
! 1779     return ret;
! 1780 }
! 1781 
  1782 void SqlHandle::requireDetachedFetchBindings() {
  1783     SQLRETURN ret = detachFetchBindings();
  1784     if (!SQL_SUCCEEDED(ret)) {
  1785         ThrowFetchCleanupError(_type, _handle, ret, "Detaching retained fetch buffers");

Lines 1899-1907

  1899     }
  1900     if (statementHandle->isImplicitlyFreed()) {
  1901         return SQL_INVALID_HANDLE;
  1902     }
! 1903     if (SQLRETURN ret = BeginResultTransition(statementHandle); !SQL_SUCCEEDED(ret)) {
  1904         return ret;
  1905     }
  1906     if (!SQLFreeStmt_ptr) {
  1907         DriverLoader::getInstance().loadDriver();

Lines 2051-2059

  2051                           const py::object& schemaObj, const py::object& tableObj,
  2052                           const py::object& columnObj) {
  2053     PERF_TIMER("SQLColumns_wrap");
  2054     if (SQLRETURN ret = BeginResultTransition(StatementHandle); !SQL_SUCCEEDED(ret)) {
! 2055         return ret;
  2056     }
  2057     if (!SQLColumns_ptr) {
  2058         ThrowStdException("SQLColumns function not loaded");
  2059     }

Lines 2123-2135

  2123                              const std::string& message) {
  2124     py::tuple record = py::make_tuple(py::str(state), py::str(message));
  2125     if (PyList_Append(records.ptr(), record.ptr()) < 0)
  2126         throw py::error_already_set();
! 2127 }
  2128 
  2129 static void AppendDiagRecords(SQLHANDLE rawHandle, SQLSMALLINT handleType, py::handle records,
! 2130                               bool internalTruncation = false,
! 2131                               FetchBindingDiagnostics* nativeRecords = nullptr) {
  2132     // Iterate through all available diagnostic records
  2133     for (SQLSMALLINT recNumber = 1;; recNumber++) {
  2134         SQLWCHAR sqlState[6] = {0};
  2135         if (internalTruncation && SQLGetDiagField_ptr) {

Lines 2203-2212

  2203         error.discard_as_unraisable("fetch binding diagnostics");
  2204     } catch (const std::exception& error) {
  2205         if (!preserveFailure)
  2206             throw;
! 2207         std::fputs("mssql-python: failed to append fetch binding diagnostics: ", stderr);
! 2208         std::fputs(error.what(), stderr);
  2209         std::fputc('\n', stderr);
  2210     }
  2211 }

Lines 3445-3453

  3445 SQLRETURN SQLFetch_wrap(SqlHandlePtr StatementHandle) {
  3446     PERF_TIMER("SQLFetch_wrap");
  3447     if (SQLRETURN ret = StatementHandle->detachFetchBindings(); !SQL_SUCCEEDED(ret)) {
  3448         return ret;
! 3449     }
  3450     LOG("SQLFetch: Fetching next row for statement_handle=%p", (void*)StatementHandle->get());
  3451     if (!SQLFetch_ptr) {
  3452         LOG("SQLFetch: Function pointer not initialized, loading driver");
  3453         DriverLoader::getInstance().loadDriver();  // Load the driver

Lines 4507-4515

  4507     SQLRETURN ret = SQL_SUCCESS;
  4508     const bool useWideChar = (charCtype == SQL_C_WCHAR);
  4509     auto bindColumn = [bindings](SQLHSTMT stmt, SQLUSMALLINT column, SQLSMALLINT cType,
  4510                                  SQLPOINTER data, SQLLEN length, SQLLEN* indicators) -> SQLRETURN {
! 4511         if (bindings) {
  4512             bindings->push_back({column, cType, data, length, indicators});
  4513             return SQL_SUCCESS;
  4514         }
  4515         PERF_TIMER("fetch_bindings::SQLBindCol");

Lines 4583-4591

  4583                                      sizeof(SQLCHAR), buffers.indicators[col - 1].data());
  4584                 break;
  4585             case SQL_REAL:
  4586                 buffers.realBuffers[col - 1].resize(fetchSize);
! 4587                 ret = bindColumn(hStmt, col, SQL_C_FLOAT, buffers.realBuffers[col - 1].data(),
  4588                                      sizeof(SQLREAL), buffers.indicators[col - 1].data());
  4589                 break;
  4590             case SQL_DECIMAL:
  4591             case SQL_NUMERIC:

Lines 4639-4647

  4639                 // TODO: handle variable length data correctly. This logic wont
  4640                 // suffice
  4641                 HandleZeroColumnSizeAtFetch(columnSize);
  4642                 buffers.charBuffers[col - 1].resize(fetchSize * columnSize);
! 4643                 ret = bindColumn(hStmt, col, SQL_C_BINARY, buffers.charBuffers[col - 1].data(),
  4644                                      columnSize, buffers.indicators[col - 1].data());
  4645                 break;
  4646             case SQL_SS_TIMESTAMPOFFSET:
  4647                 buffers.datetimeoffsetBuffers[col - 1].resize(fetchSize);

Lines 5215-5225

  5215     ResultMetadataFailureGuard metadataFailure(StatementHandle->resultMetadata, ret);
  5216     if (fetchSize <= 0) {
  5217         ThrowStdException("Native fetchmany requires a positive fetch size");
  5218     }
! 5219     auto plan = StatementHandle->fetchBindings.snapshot();
! 5220     const auto metadataSnapshot = StatementHandle->resultMetadata.snapshot();
! 5221     if (plan && !plan->matches(metadataSnapshot, fetchSize, charEncoding, wcharEncoding, charCtype)) {
  5222         ret = StatementHandle->detachFetchBindings(nullptr, messages);
  5223         if (!SQL_SUCCEEDED(ret)) {
  5224             return ret;
  5225         }

Lines 5333-5341

  5333             StatementHandle->fetchBindings.install(plan);
  5334             FetchBindingDiagnostics diagnostics;
  5335             try {
  5336                 ret = plan->attach(hStmt, messages && !messages.is_none() ? &diagnostics : nullptr);
! 5337             } catch (...) {
  5338                 AppendFetchBindingDiagnostics(messages, diagnostics, true);
  5339                 throw;
  5340             }
  5341             AppendFetchBindingDiagnostics(messages, diagnostics, !SQL_SUCCEEDED(ret));

Lines 5361-5369

  5361         ret = StatementHandle->detachFetchBindings(nullptr, messages);
  5362         if (!SQL_SUCCEEDED(ret)) {
  5363             return ret;
  5364         }
! 5365     }
  5366     // Initialize column buffers
  5367     ColumnBuffers buffers(numCols, fetchSize);
  5368     FetchStateGuard fetchStateGuard(StatementHandle, messages);

mssql_python/pybind/fetch_bindings.hpp

Lines 109-120

  109         void operator()(FetchBindingPlan* plan) const noexcept {
  110             if (plan->driverMayReference.load()) {
  111                 // Final owner only: freeing this allocation could leave driver
  112                 // pointers dangling after failed native cleanup or finalization.
! 113                 std::fputs("mssql-python: retaining fetch buffers after unconfirmed native "
! 114                            "cleanup until process exit\n", stderr);
! 115                 return;
! 116             }
  117             delete plan;
  118         }
  119     };

Lines 148-157

  148 
  149     void install(const std::shared_ptr<FetchBindingPlan>& plan) {
  150         std::lock_guard<std::mutex> lock(mutex_);
  151         if (plan_) {
! 152             throw std::logic_error("Fetch bindings must be detached before replacement");
! 153         }
  154         plan_ = plan;
  155         hasPlan_.store(true, std::memory_order_release);
  156     }

Lines 182-190

  182     }
  183 
  184     bool eligible() const { return eligible_.load(); }
  185 
! 186     void disableReuse() { eligible_ = false; }
  187 
  188   private:
  189     mutable std::mutex mutex_;
  190     std::shared_ptr<FetchBindingPlan> plan_;


📋 Files Needing Attention

📉 Files with overall lowest coverage (click to expand)
mssql_python.pybind.performance_counter.hpp: 0.7%
mssql_python.pybind.logger_bridge.cpp: 57.9%
mssql_python.pybind.ddbc_bindings.h: 62.9%
mssql_python.pybind.logger_bridge.hpp: 70.8%
mssql_python.pybind.ddbc_bindings.cpp: 77.2%
mssql_python.pybind.connection.connection_pool.cpp: 82.3%
mssql_python.pybind.connection.connection.cpp: 82.9%
mssql_python.logging.py: 86.2%
mssql_python.pooling.py: 90.1%
mssql_python.pybind.fetch_bindings.hpp: 90.5%

🔗 Quick Links

⚙️ Build Summary 📋 Coverage Details

View Azure DevOps Build

Browse Full Coverage Report

Copilot AI review requested due to automatic review settings September 25, 2026 06:53

Copilot AI 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.

Copilot review overview

🔵 Needs a closer look

Add regression coverage and preserve cleanup diagnostics across all transition and close paths.

Review effort: Lite
Findings: 1 High severity · 1 Medium severity

Open (2)

Replace banned formatted C output without entering Python during cleanup. Add isolated profiler regression checks for binding reuse and size, encoding, and result-set transitions.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 25, 2026 07:56

Copilot AI 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.

Copilot review overview

🔵 Needs a closer look

Unresolved lifetime-failure coverage, oversized buffer retention, and teardown-safety concerns remain.

Review effort: Lite
Findings: 1 Low severity

Open (1)
Resolved since last review (2)

Comment thread tests/test_025_profiler.py
Keep SQL_CHAR fixed while changing ASCII to Latin-1 so the encoding-only cache miss reaches the native fetch path.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 25, 2026 08:10

Copilot AI 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.

Copilot review overview

🔵 Needs a closer look

Native cleanup can leak plans after failed unbinds, and recovery/close paths lack regression coverage.

Review effort: Lite
Findings: None

Resolved since last review (1)

Exercise real statement-owned plans with injected unbind and rows-fetched-pointer cleanup failures, runtime lifetime assertions, and successful release recovery. Keep the native target opt-in and run it in the existing Ubuntu PR jobs.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 25, 2026 09:10

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

Fix the Ubuntu Release configuration and avoid std::cerr in the post-shutdown destructor path.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 Medium severity · 1 Low severity

Open (2)
Resolved since last review (1)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Avoid std::cerr in destructor after Python shutdown

mssql_python/​pybind/​ddbc_bindings.cpp:1672

This destructor is explicitly documented below as a path that must not log because it can run after Python shutdown, but the new failure path writes through std::cerr. C++ iostream teardown is not guaranteed to be usable from such a destructor; use the existing non-throwing native fputs/stderr style or suppress this diagnostic.

Comment thread eng/pipelines/pr-validation-pipeline.yml Outdated
Comment thread mssql_python/pybind/CMakeLists.txt Outdated
Revert 1c05d11 after the user rejected the expanded scope. Restore the exact be219eb source tree, preserving the earlier review fixes and profiler regressions.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 25, 2026 09:19

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

Teardown-safe diagnostics and fault-injection coverage for failed cleanup are still needed.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 Medium severity · 1 Low severity

Open (2)
Resolved since last review (1)

Comment thread mssql_python/pybind/fetch_bindings.hpp
Exercise an injected SQL_UNBIND failure in the existing isolated profiler test suite. Assert that failed cleanup blocks reuse and rebinding, then verify recovery and EOF without changing production or build configuration.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Check the existing SQLFetchScroll call counter alongside plan allocation and binding counts in the single cleanup regression.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 25, 2026 09:43

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

Cleanup failure paths remain insufficiently covered, and the test scope conflicts with the stated validation evidence.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 2 Medium severity · 1 Low severity

Open (3)
Resolved since last review (1)

Comment thread mssql_python/pybind/ddbc_bindings.cpp
Comment thread tests/test_025_profiler.py Outdated
Parameterize the existing isolated cleanup regression to fail either SQL_UNBIND or clearing SQL_ATTR_ROWS_FETCHED_PTR. Preserve no-allocation, no-bind, no-fetch, retry, and recovery assertions without changing production or build configuration.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 25, 2026 10:30

Copilot AI 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.

Copilot review overview

🔵 Needs a closer look

Two moderate cleanup and idempotency issues remain unresolved.

Review effort: Lite
Findings: 2 Medium severity

Open (2)
Resolved since last review (1)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Avoid std::cerr in destructor during shutdown

mssql_python/​pybind/​ddbc_bindings.cpp:1672

This destructor can run during interpreter/process teardown, but the new std::cerr use depends on the C++ iostream runtime and conflicts with the nearby rule that destructors must not log (ddbc_bindings.cpp:1713-1718). If cleanup fails late in shutdown, this can access torn-down iostream state and turn an already-handled cleanup failure into a crash. Use the existing C-style stderr path (or omit this diagnostic) instead of std::cerr.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-size: large Substantial code update

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants