Repository navigation
PERF: Reuse cursor-scoped state for row fetching - #858
Jahnvi Thakkar (jahnvi480) wants to merge 3 commits into
Conversation
Recover the exact cumulative Source05 native-fetch implementation and its regression coverage against frozen main. Preserve watcher invalidation, ownership, fallback and failure controls without later redesigns. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
PR Performance Report
|
| Database task | Unix / SQL Server 2022 | Unix / SQL Server 2025 |
|---|---|---|
| Row-by-row fetching | 726.4% slower | 721.4% slower |
| 10,000 scalar values / fetchval() (debug disabled) | 951.5% slower | 951.6% slower |
The largest recorded phase increases for these tasks are shown below. Phase timings are supporting evidence, not root-cause proof.
Coverage: 2 of 2 environments completed. Advisory result; does not block merging.
Measured timings
| Environment | Database task | Before | After | Change |
|---|---|---|---|---|
| Unix / SQL Server 2022 | Row-by-row fetching | 14.624 ms | 119.899 ms | +726.4% |
| Unix / SQL Server 2022 | 10,000 scalar values / fetchval() (debug disabled) | 106.608 ms | 1119.728 ms | +951.5% |
| Unix / SQL Server 2025 | Row-by-row fetching | 14.415 ms | 117.243 ms | +721.4% |
| Unix / SQL Server 2025 | 10,000 scalar values / fetchval() (debug disabled) | 105.724 ms | 1111.795 ms | +951.6% |
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
Row-by-row fetching: py::fetchone::cpp_call +1.920 ms; ddbc::FetchOne_wrap +1.590 ms; ddbc::SQLGetData_wrap +1.489 ms. Call changes: ddbc::FetchSingleRow (added, removed, or intermittent); ddbc::FetchSingleRow::SQL_UNBIND (added, removed, or intermittent); ddbc::NativeCursor::fallback (added, removed, or intermittent).
Batched row fetching: py::fetchmany::row_wrap +2.855 ms; ddbc::FetchBatchData +2.036 ms; ddbc::FetchMany_wrap +1.940 ms. Call changes: ddbc::NativeCursor::fallback (added, removed, or intermittent); ddbc::SQLNumResultCols_wrap (51 -> 1 calls).
Row fetching in batches of 100: ddbc::FetchBatchData +1.466 ms; ddbc::FetchBatchData::SQLFetchScroll_call +0.980 ms; ddbc::FetchMany_wrap +0.891 ms. Call changes: ddbc::NativeCursor::fallback (added, removed, or intermittent); ddbc::SQLNumResultCols_wrap (501 -> 1 calls).
Row fetching in batches of 10,000: py::fetchmany::cpp_call +15.241 ms; ddbc::FetchMany_wrap +13.191 ms; ddbc::SQLBindColums +11.286 ms. Call changes: ddbc::NativeCursor::fallback (added, removed, or intermittent); ddbc::SQLNumResultCols_wrap (6 -> 1 calls).
Repeated positional queries: py::fetchone::cpp_call +0.046 ms; ddbc::FetchOne_wrap +0.024 ms; py::execute::param_prep +0.021 ms. Call changes: ddbc::FetchSingleRow (added, removed, or intermittent); ddbc::FetchSingleRow::SQL_UNBIND (added, removed, or intermittent); ddbc::NativeCursor::fallback (added, removed, or intermittent).
Repeated named-parameter queries: py::execute::cpp_call +0.601 ms; ddbc::SQLExecute_wrap +0.589 ms; py::execute::param_prep +0.143 ms. Call changes: ddbc::FetchSingleRow (added, removed, or intermittent); ddbc::FetchSingleRow::SQL_UNBIND (added, removed, or intermittent); ddbc::NativeCursor::fallback (added, removed, or intermittent).
10,000 scalar values / fetchval() (debug disabled): py::fetchone::cpp_call +8.316 ms; ddbc::FetchOne_wrap +5.571 ms; ddbc::SQLGetData_wrap +3.354 ms. Call changes: ddbc::FetchSingleRow (added, removed, or intermittent); ddbc::FetchSingleRow::SQL_UNBIND (added, removed, or intermittent); ddbc::NativeCursor::fallback (added, removed, or intermittent).
Unix / SQL Server 2025
Row-by-row fetching: py::fetchone::cpp_call +1.921 ms; ddbc::FetchOne_wrap +1.534 ms; ddbc::SQLGetData_wrap +1.405 ms. Call changes: ddbc::FetchSingleRow (added, removed, or intermittent); ddbc::FetchSingleRow::SQL_UNBIND (added, removed, or intermittent); ddbc::NativeCursor::fallback (added, removed, or intermittent).
Batched row fetching: py::fetchmany::row_wrap +3.243 ms; ddbc::FetchBatchData +1.102 ms; ddbc::FetchMany_wrap +1.054 ms. Call changes: ddbc::NativeCursor::fallback (added, removed, or intermittent); ddbc::SQLNumResultCols_wrap (51 -> 1 calls).
Row fetching in batches of 100: ddbc::FetchBatchData::SQLFetchScroll_call +1.319 ms; ddbc::FetchBatchData +1.123 ms; py::fetchmany::cpp_call +1.072 ms. Call changes: ddbc::NativeCursor::fallback (added, removed, or intermittent); ddbc::SQLNumResultCols_wrap (501 -> 1 calls).
Row fetching in batches of 10,000: py::fetchmany::cpp_call +4.743 ms; ddbc::FetchBatchData +2.424 ms; ddbc::FetchMany_wrap +2.121 ms. Call changes: ddbc::NativeCursor::fallback (added, removed, or intermittent); ddbc::SQLNumResultCols_wrap (6 -> 1 calls).
Repeated positional queries: py::fetchone::cpp_call +0.043 ms; ddbc::FetchOne_wrap +0.033 ms; ddbc::SQLGetData_wrap +0.011 ms. Call changes: ddbc::FetchSingleRow (added, removed, or intermittent); ddbc::FetchSingleRow::SQL_UNBIND (added, removed, or intermittent); ddbc::NativeCursor::fallback (added, removed, or intermittent).
Repeated named-parameter queries: py::execute::cpp_call +0.062 ms; ddbc::SQLExecute_wrap +0.046 ms; py::fetchone::cpp_call +0.017 ms. Call changes: ddbc::FetchSingleRow (added, removed, or intermittent); ddbc::FetchSingleRow::SQL_UNBIND (added, removed, or intermittent); ddbc::NativeCursor::fallback (added, removed, or intermittent).
10,000 scalar values / fetchval() (debug disabled): py::fetchone::cpp_call +7.664 ms; ddbc::FetchOne_wrap +4.472 ms; ddbc::SQLGetData_wrap +2.923 ms. Call changes: ddbc::FetchSingleRow (added, removed, or intermittent); ddbc::FetchSingleRow::SQL_UNBIND (added, removed, or intermittent); ddbc::NativeCursor::fallback (added, removed, or intermittent).
All database tasks and timings
Unix / SQL Server 2022
| Database task | Before | After | Paired change | Result |
|---|---|---|---|---|
| Connection opening | 10.631 ms | 10.464 ms | -1.1% | no signal |
| SELECT queries | 1.105 ms | 1.082 ms | -1.0% | no signal |
| Row insertion | 32.115 ms | 31.884 ms | -0.8% | no signal |
| Executemany inserts | 156.950 ms | 158.082 ms | +0.5% | no signal |
| Fetch-all queries | 122.298 ms | 122.593 ms | +0.2% | no signal |
| Row-by-row fetching | 14.624 ms | 119.899 ms | +726.4% | consistent slowdown |
| Batched row fetching | 116.623 ms | 118.590 ms | +1.7% | no signal |
| Transaction commit and rollback | 103.704 ms | 104.845 ms | +1.0% | no signal |
| Arrow row fetching | 89.528 ms | 92.193 ms | +2.7% | no signal |
| 100,000-row insertion | 439.812 ms | 435.086 ms | +0.9% | no signal |
| Row fetching in batches of 100 | 122.063 ms | 123.025 ms | +0.1% | no signal |
| Row fetching in batches of 10,000 | 130.064 ms | 144.877 ms | +10.0% | no signal |
| Repeated positional queries | 31.385 ms | 32.683 ms | +3.7% | no signal |
| Repeated named-parameter queries | 33.454 ms | 35.159 ms | +6.6% | no signal |
| Legacy 100,000-row insertion | 354.484 ms | 353.872 ms | -0.6% | no signal |
| Insertion with explicit input sizes | 487.652 ms | 497.806 ms | +1.4% | no signal |
| Joined aggregation queries | 177.071 ms | 177.207 ms | +0.1% | no signal |
| Large joined-result fetching | 195.364 ms | 186.528 ms | -4.9% | no signal |
| 1.2-million-row fetching | 3424.716 ms | 3444.079 ms | +0.9% | no signal |
| Common table expression queries | 5.553 ms | 5.623 ms | +0.3% | no signal |
| 256 KiB VARCHAR(MAX) / fetchall() | 1.684 ms | 1.430 ms | -7.1% | no signal |
| 10,000 scalar values / fetchval() (debug disabled) | 106.608 ms | 1119.728 ms | +951.5% | consistent slowdown |
Unix / SQL Server 2025
| Database task | Before | After | Paired change | Result |
|---|---|---|---|---|
| Connection opening | 98.553 ms | 98.376 ms | -0.9% | no signal |
| SELECT queries | 1.040 ms | 1.087 ms | +4.9% | no signal |
| Row insertion | 34.765 ms | 35.074 ms | +1.5% | no signal |
| Executemany inserts | 154.367 ms | 154.100 ms | -0.2% | no signal |
| Fetch-all queries | 120.922 ms | 121.838 ms | +1.5% | no signal |
| Row-by-row fetching | 14.415 ms | 117.243 ms | +721.4% | consistent slowdown |
| Batched row fetching | 118.411 ms | 118.911 ms | +0.6% | no signal |
| Transaction commit and rollback | 115.956 ms | 115.688 ms | -0.4% | no signal |
| Arrow row fetching | 93.197 ms | 95.431 ms | +2.1% | no signal |
| 100,000-row insertion | 440.972 ms | 433.064 ms | -1.8% | no signal |
| Row fetching in batches of 100 | 120.653 ms | 122.088 ms | +1.4% | no signal |
| Row fetching in batches of 10,000 | 138.515 ms | 141.452 ms | +2.4% | no signal |
| Repeated positional queries | 33.808 ms | 35.193 ms | +4.6% | no signal |
| Repeated named-parameter queries | 36.942 ms | 37.899 ms | +2.0% | no signal |
| Legacy 100,000-row insertion | 348.846 ms | 356.094 ms | +2.3% | no signal |
| Insertion with explicit input sizes | 485.189 ms | 496.128 ms | +4.1% | no signal |
| Joined aggregation queries | 161.304 ms | 159.699 ms | -0.6% | no signal |
| Large joined-result fetching | 185.933 ms | 182.601 ms | -1.8% | no signal |
| 1.2-million-row fetching | 3408.471 ms | 3440.739 ms | +0.9% | no signal |
| Common table expression queries | 5.146 ms | 5.133 ms | -0.4% | no signal |
| 256 KiB VARCHAR(MAX) / fetchall() | 1.497 ms | 1.517 ms | +2.4% | no signal |
| 10,000 scalar values / fetchval() (debug disabled) | 105.724 ms | 1111.795 ms | +951.6% | consistent slowdown |
Build and measurement details
PR head: e6f9888ae92351825fdb964d7332f76114ef9ae4
Base: bd0aaf89c724a92c934b68bca41469655bfc733c
Measured merge: ccf0786de75a5ad982f24e0221e2692cb6b77fc7
- Unix / SQL Server 2022: Python 3.12.3, x86_64, SQL 16.0.4305.5; 5 paired comparisons and 1 warmup.
- Unix / SQL Server 2025: Python 3.12.3, x86_64, SQL 17.0.5015.4; 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.
There was a problem hiding this comment.
🔵 Needs a closer look
Native ownership and watcher-based dispatch need human review, with fallback compatibility fixes, cross-platform validation, and performance qualification still outstanding.
2 open issues
What changed in this PR
This PR reduces repeated single-row fetch overhead by reusing cursor-scoped native state while retaining Python fallback paths.
Changes:
- Adds watcher-based native dispatch and garbage-collector-visible ownership for eligible single-row operations.
- Shares the native single-row pipeline, tracks complete metadata, and reuses successful column unbinding.
- Adds parity, mutation, lifetime, operation-count, and isolated failure-control tests.
| File | Description |
|---|---|
| tests/test_single_row_pipeline.py | Tests single-row parity, ownership, and operation counts. |
| tests/test_native_fetch_dispatch.py | Tests dispatch, mutation, lifetime, and failure recovery. |
| mssql_python/pybind/row_factory.hpp | Adds single-row construction and reusable attribute names. |
| mssql_python/pybind/result_metadata.hpp | Distinguishes complete metadata from partial prefixes. |
| mssql_python/pybind/native_fetch.hpp | Implements native state, watchers, dispatch, and fallback. |
| mssql_python/pybind/ddbc_bindings.h | Tracks reusable column-unbinding state. |
| mssql_python/pybind/ddbc_bindings.cpp | Shares single-row fetching and registers native bindings. |
| mssql_python/pybind/CMakeLists.txt | Gates fault controls behind profiling-enabled builds. |
| mssql_python/cursor.py | Adds invalidation, completion helpers, and dispatch installation. |
| mssql_python/_native_fetch.py | Defines eligibility policy and installation rollback. |
Review effort: Balanced
Preserve fallback semantics for dict subclasses and keyword binding, accept CPython 3.15's ordinary dictionary descriptor, and initialize stable policy dependencies before capture. Restore logging state between tests and consolidate duplicate fetch coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
| if (fetchSize == 1 && lobColumns.empty()) { | ||
| py::list row; | ||
| ret = FetchSingleRow(StatementHandle, row, charEncoding, wcharEncoding, charCtype, messages); |
📊 Code Coverage Report
Diff CoverageDiff: main...HEAD, staged and unstaged changes
Summary
mssql_python/_native_fetch.pyLines 95-103 95 return None
96 from mssql_python.connection import Connection
97
98 if type(connection) is not Connection:
! 99 return None
100 descriptor = vars(Connection).get("__dict__")
101 if (
102 Connection.__getattribute__ is not object.__getattribute__
103 or type(descriptor) is not GetSetDescriptorTypeLines 104-112 104 # CPython 3.15 shares the ordinary instance-dict descriptor on object.
105 or descriptor.__objclass__ not in (Connection, object)
106 or descriptor.__name__ != "__dict__"
107 ):
! 108 return None
109 connection_data = object.__getattribute__(connection, "__dict__")
110 ddbc_bindings._watch_fetch_dependencies(
111 self.observer,
112 (data, connection_data),Lines 146-154 146 for name, value in original.items()
147 ):
148 return None
149 if perf_timer.is_enabled() or self.logger.is_debug_enabled:
! 150 return None
151 if (
152 self.row_type.__new__ is not object.__new__
153 or self.row_type.__setattr__ is not object.__setattr__
154 or any(Lines 156-164 156 for base in Connection.__mro__
157 for name in ("_decoding_generation", "_converters_generation")
158 )
159 ):
! 160 return None
161 if any(name in data for name in vars(self.cursor_type)):
162 return None
163 for cached, generation in (
164 ("_cached_decoding_generation", "_decoding_generation"),Lines 167-175 167 if (
168 type(data.get(cached)) is not int
169 or type(connection_data.get(generation)) is not int
170 ):
! 171 return None
172 if (
173 data.get("closed") is not False
174 or data.get("_has_result_set") is not True
175 or data.get("_skip_increment_for_next_fetch") is not FalseLines 237-247 237
238
239 class _InstallRollbackError(RuntimeError):
240 def __init__(self, installation_error, cleanup_errors):
! 241 self.installation_error = installation_error
! 242 self.cleanup_errors = tuple(cleanup_errors)
! 243 super().__init__(
244 "Native fetch installation and rollback both failed",
245 installation_error,
246 self.cleanup_errors,
247 )Lines 249-257 249
250 def install(cursor_type, row_type, namespace):
251 observer = ddbc_bindings._fetch_observer
252 if observer is None or not isinstance(getattr(row_type, "_fast_create", None), FunctionType):
! 253 return None
254 methods = ("fetchone", "fetchmany", "fetchval", "__next__")
255 fallbacks = tuple(getattr(cursor_type, name) for name in methods)
256 entries = ddbc_bindings._native_fetch_methods
257 originals = {name: vars(cursor_type)[name] for name in methods}Lines 274-298 274 ("_next_row_index", "_rownumber", "rowcount"),
275 context,
276 )
277 if any(vars(cursor_type).get(name) is not entry for name, entry in zip(methods, entries)):
! 278 raise RuntimeError("Cursor methods changed during native fetch installation")
279 activate(runtime)
280 return runtime
! 281 except BaseException as installation_error:
282 # Unpublished entries can only forward to originals until activation.
! 283 cleanup_errors = []
! 284 for name, entry in zip(methods, entries):
! 285 try:
! 286 if vars(cursor_type).get(name) is entry:
! 287 setattr(cursor_type, name, originals[name])
! 288 except BaseException as cleanup_error:
! 289 cleanup_errors.append((name, cleanup_error))
! 290 survivors = tuple(
291 name for name, entry in zip(methods, entries) if vars(cursor_type).get(name) is entry
292 )
! 293 if survivors and not cleanup_errors:
! 294 cleanup_errors.append(
295 (
296 "descriptors",
297 RuntimeError("Native fetch descriptors survived rollback", survivors),
298 )Lines 298-310 298 )
299 )
300 # Retained descriptors must retain their forwarding functions. A partial
301 # rollback is terminal, not a context that a later install may reuse.
! 302 terminalize = disable if cleanup_errors else abort
! 303 try:
! 304 terminalize()
! 305 except BaseException as cleanup_error:
! 306 cleanup_errors.append(("context", cleanup_error))
! 307 if cleanup_errors:
! 308 raise _InstallRollbackError(installation_error, cleanup_errors) from installation_error
! 309 raisemssql_python/cursor.pyLines 2897-2911 2897 def _complete_fetchone(self, row_data, ret):
2898 """Complete an already-consumed fetch, including mutation/error fallback."""
2899 check_error(_SQL_HANDLE_STMT, self.hstmt, ret)
2900 if ret == _SQL_NO_DATA:
! 2901 if self._next_row_index == 0 and self.description is not None:
! 2902 self.rowcount = 0
! 2903 return None
2904
2905 if self._skip_increment_for_next_fetch:
! 2906 self._skip_increment_for_next_fetch = False
! 2907 self._next_row_index += 1
2908 else:
2909 self._increment_rownumber()
2910 self.rowcount = self._next_row_index
2911 return self._wrap_fetchone(row_data)Lines 2916-2924 2916 if not converter_map and not self._uuid_str_indices:
2917 return Row._fast_create(
2918 row_data, column_map, self, column_map_lower, self._cached_result_columns
2919 )
! 2920 return Row(
2921 row_data,
2922 column_map,
2923 cursor=self,
2924 converter_map=converter_map,mssql_python/pybind/ddbc_bindings.cppLines 5079-5087 5079 if (fetchSize == 1 && lobColumns.empty()) {
5080 py::list row;
5081 ret = FetchSingleRow(StatementHandle, row, charEncoding, wcharEncoding, charCtype, messages);
5082 if (SQL_SUCCEEDED(ret))
! 5083 rows.append(row);
5084 return ret;
5085 }
5086
5087 // Initialized to 0 for LOB path counter; overwritten by ODBC in non-LOB path;mssql_python/pybind/native_fetch.hppLines 34-42 34 PyObject* value) {
35 if (event != PyDict_EVENT_MODIFIED || dict != internalDict ||
36 (key != internalKey &&
37 (!key || !internalKey || !PyUnicode_CheckExact(key) ||
! 38 PyUnicode_Compare(key, internalKey) != 0)) ||
39 value != internalValue)
40 mutationEpoch.fetch_add(1, std::memory_order_relaxed);
41 return 0;
42 }Lines 271-279 271 while (PyDict_Next(dict, &position, &key, &value)) {
272 if (PyUnicode_CheckExact(key) && PyUnicode_GET_LENGTH(key) == length) {
273 const int comparison = PyUnicode_CompareWithASCIIString(key, name);
274 if (PyErr_Occurred())
! 275 throw py::error_already_set();
276 if (comparison == 0)
277 return value;
278 }
279 }Lines 297-305 297 } else if (PyUnicode_GET_LENGTH(key) ==
298 static_cast<Py_ssize_t>(sizeof("_native_fetch_state") - 1)) {
299 const int comparison = PyUnicode_CompareWithASCIIString(key, "_native_fetch_state");
300 if (PyErr_Occurred())
! 301 throw py::error_already_set();
302 if (comparison == 0)
303 result.value = value;
304 }
305 }Lines 307-315 307 }
308
309 inline bool owned_by(State* state, Context* context, PyObject* receiver) {
310 if (state->context != reinterpret_cast<PyObject*>(context) || !state->owner)
! 311 return false;
312 #if PY_VERSION_HEX >= 0x030D0000
313 PyObject* owner = nullptr;
314 const int result = PyWeakref_GetRef(state->owner, &owner);
315 if (result < 0)Lines 319-327 319 #else
320 // The GIL and the call's strong receiver reference cover this borrowed read.
321 PyObject* owner = PyWeakref_GetObject(state->owner);
322 if (!owner)
! 323 throw py::error_already_set();
324 return owner == receiver;
325 #endif
326 }Lines 339-350 339 reinterpret_cast<void*>(PyObject_GenericSetAttr))
340 return false;
341 auto attributes = steal(PyType_GetDict(Py_TYPE(receiver)));
342 if (!attributes)
! 343 throw py::error_already_set();
344 if (exact_item(attributes.ptr(), "__dict__") != context->dictDescriptor ||
345 exact_item(attributes.ptr(), "_native_fetch_state"))
! 346 return false;
347 auto* handle = exact_item(dict, "hstmt");
348 auto* index = exact_item(dict, "_next_row_index");
349 auto* messages = exact_item(dict, "messages");
350 return exact_item(dict, "closed") == Py_False && handle && handle != Py_None &&Lines 407-415 407 #endif
408 weakOwner = PyWeakref_NewRef(receiver, nullptr);
409 auto owner = steal(weakOwner);
410 if (!owner)
! 411 throw py::error_already_set();
412 PyObject* allocated;
413 #ifdef MSSQL_NATIVE_FETCH_TEST_FAULTS
414 if (test_fault(context, 2))
415 allocated = PyErr_NoMemory();Lines 418-426 418 allocated = PyType_GenericAlloc(
419 reinterpret_cast<PyTypeObject*>(context->stateType), 0);
420 auto result = steal(allocated);
421 if (!result)
! 422 throw py::error_already_set();
423 auto* state = reinterpret_cast<State*>(result.ptr());
424 #ifdef MSSQL_NATIVE_FETCH_TEST_FAULTS
425 state->testContext = context;
426 ++context->faults.statesAllocated;Lines 441-453 441 // Allocation can run GC callbacks. Revalidate before a callback-free write,
442 // retaining the old value so publication cannot release its last reference.
443 auto now = instance_dict(receiver);
444 if (!now)
! 445 throw py::error_already_set();
446 entry = state_entry(dict.ptr());
447 if (!now.is(dict) || entry.value != old.ptr() ||
448 !storage_admissible(context, receiver, dict.ptr(), entry))
! 449 return {};
450 int published;
451 #ifdef MSSQL_NATIVE_FETCH_TEST_FAULTS
452 if (test_fault(context, 4)) {
453 PyErr_NoMemory();Lines 455-463 455 } else
456 #endif
457 published = PyDict_SetItem(dict.ptr(), context->stateKey, result.ptr());
458 if (published < 0)
! 459 throw py::error_already_set();
460 #ifdef MSSQL_NATIVE_FETCH_TEST_FAULTS
461 ++context->faults.publications;
462 #endif
463 return result;Lines 490-498 490 auto plan = std::make_shared<Plan>(std::move(dict), epoch);
491 py::object values = rt[2](py::handle(self), plan->cursorDict);
492 if (!values.is_none()) {
493 if (!PyTuple_CheckExact(values.ptr()) || PyTuple_GET_SIZE(values.ptr()) != InputCount)
! 494 throw py::type_error("Invalid native fetch plan");
495 plan->inputs = std::move(values);
496 if (!PyDict_CheckExact(plan->at(CursorDict).ptr()) ||
497 !PyDict_CheckExact(plan->at(ConnectionDict).ptr()) ||
498 !PyDict_CheckExact(plan->at(ColumnMap).ptr()) ||Lines 503-516 503 !PyType_Check(plan->at(ConnectionType).ptr()) ||
504 !PyLong_CheckExact(plan->at(NextIndex).ptr()) ||
505 !PyLong_CheckExact(plan->at(ArraySize).ptr()) ||
506 !PyCallable_Check(plan->at(Factory).ptr()))
! 507 throw py::type_error("Invalid native fetch plan fields");
508 // current() relies on this watch, not on the Python policy registering it.
509 auto* observer = static_cast<Observer*>(
510 PyCapsule_GetPointer(rt[0].ptr(), "mssql_python.native_fetch.Observer"));
511 if (!observer || PyDict_Watch(observer->dict, plan->cursorDict.ptr()) < 0)
! 512 throw py::error_already_set();
513 plan->handle = plan->at(Handle).cast<SqlHandlePtr>();
514 plan->charEncoding = plan->at(CharEncoding).cast<std::string>();
515 plan->wideEncoding = plan->at(WideEncoding).cast<std::string>();
516 plan->charCtype = EffectiveCharCtypeForFetch(Lines 541-549 541 return false;
542 auto dict = instance_dict(self);
543 auto connectionDict = instance_dict(plan.at(Connection).ptr());
544 if (!dict || !connectionDict)
! 545 throw py::error_already_set();
546 // fetch() checks the key association before use. The native dictionary watch
547 // invalidates this epoch on a later state-key change; replacement is checked here.
548 return dict.is(plan.cursorDict) && connectionDict.is(plan.at(ConnectionDict)) &&
549 state->dict == dict.ptr() && owned_by(state, context, self) &&Lines 564-572 564 internalDict = plan.cursorDict.ptr();
565 internalKey = name.ptr();
566 internalValue = value.ptr();
567 if (PyDict_SetItem(plan.cursorDict.ptr(), name.ptr(), value.ptr()) < 0)
! 568 throw py::error_already_set();
569 }
570
571 inline py::object finish_python(PyObject* self, Mode mode, const py::list& values,
572 SQLRETURN ret, bool positionCommitted) {Lines 576-584 576 py::list rows;
577 if (SQL_SUCCEEDED(ret))
578 rows.append(values);
579 if (positionCommitted)
! 580 return cursor.attr("_wrap_fetchmany")(rows);
581 return cursor.attr("_complete_fetchmany")(rows, ret);
582 }
583 py::object row = positionCommitted ? cursor.attr("_wrap_fetchone")(values)
584 : cursor.attr("_complete_fetchone")(values, ret);Lines 584-592 584 : cursor.attr("_complete_fetchone")(values, ret);
585 if (mode == Mode::Value && !row.is_none())
586 return row[py::int_(0)];
587 if (mode == Mode::Next && row.is_none())
! 588 throw py::stop_iteration();
589 return row;
590 }
591
592 inline bool conversion_ineligible(py::handle data) {Lines 609-617 609 else if (length == static_cast<Py_ssize_t>(sizeof("_uuid_str_indices") - 1) &&
610 PyUnicode_CompareWithASCIIString(key, "_uuid_str_indices") == 0)
611 uuidIndices = value;
612 if (PyErr_Occurred())
! 613 throw py::error_already_set();
614 }
615 return !converters || !PyTuple_CheckExact(converters) ||
616 PyTuple_GET_SIZE(converters) != 0 || uuidIndices != Py_None;
617 }Lines 624-632 624 if (!PyUnicode_CheckExact(key))
625 return false;
626 const int comparison = PyUnicode_CompareWithASCIIString(key, "size");
627 if (PyErr_Occurred())
! 628 throw py::error_already_set();
629 if (comparison != 0)
630 return false;
631 } else if (nargs != 1 || count != 0) {
632 return false;Lines 635-643 635 return false;
636 int overflow = 0;
637 const long size = PyLong_AsLongAndOverflow(args[0], &overflow);
638 if (PyErr_Occurred())
! 639 throw py::error_already_set();
640 return overflow || size != 1;
641 }
642
643 inline bool single_size(const Plan& plan, PyObject* const* args, Py_ssize_t nargs,Lines 650-658 650 if (!PyUnicode_CheckExact(key))
651 return false;
652 const int comparison = PyUnicode_CompareWithASCIIString(key, "size");
653 if (PyErr_Occurred())
! 654 throw py::error_already_set();
655 if (comparison != 0)
656 return false;
657 }
658 PyObject* size = nargs + count ? args[0] : Py_None;Lines 662-670 662 return false;
663 int overflow = 0;
664 const long value = PyLong_AsLongAndOverflow(size, &overflow);
665 if (PyErr_Occurred())
! 666 throw py::error_already_set();
667 return !overflow && value == 1;
668 }
669
670 inline py::object fetch(PyObject* self, Context* context, Mode mode,Lines 684-692 684 return fallback(self, rt, mode, args, nargs, keywords);
685
686 py::dict dict = instance_dict(object);
687 if (!dict)
! 688 throw py::error_already_set();
689 bool needsRevalidation;
690 auto stateOwner = acquire_state(context, self, dict, needsRevalidation);
691 auto* state = reinterpret_cast<State*>(stateOwner.ptr());
692 auto admittedDict = needsRevalidation ? instance_dict(self) : dict;Lines 690-701 690 auto stateOwner = acquire_state(context, self, dict, needsRevalidation);
691 auto* state = reinterpret_cast<State*>(stateOwner.ptr());
692 auto admittedDict = needsRevalidation ? instance_dict(self) : dict;
693 if (!admittedDict)
! 694 throw py::error_already_set();
695 if (!admittedDict.is(dict) ||
696 (needsRevalidation && state && !associated(state, context, self, dict.ptr())))
! 697 return fallback(self, rt, mode, args, nargs, keywords);
698 const uint64_t epoch = mutationEpoch.load(std::memory_order_relaxed);
699 auto plan = state && state->plan ? *state->plan : nullptr;
700 bool stale = !plan || plan->epoch != epoch || !plan->cursorDict.is(dict);
701 if (!stale && !plan->inputs.is_none()) {Lines 707-715 707 if (stale) {
708 plan = prepare(self, rt, dict, epoch);
709 auto preparedDict = instance_dict(self);
710 if (!preparedDict)
! 711 throw py::error_already_set();
712 if (state && state->plan && preparedDict.is(dict) &&
713 associated(state, context, self, dict.ptr())) {
714 auto old = std::move(*state->plan);
715 *state->plan = plan;Lines 724-735 724 return fallback(self, rt, mode, args, nargs, keywords);
725 auto metadata = plan->handle->resultMetadata.snapshot();
726 if (metadata.generation != plan->metadata.generation ||
727 metadata.metadata != plan->metadata.metadata)
! 728 return fallback(self, rt, mode, args, nargs, keywords);
729 }
730 if (mode == Mode::Value && PyList_GET_SIZE(plan->at(Description).ptr()) == 0)
! 731 return fallback(self, rt, mode, args, nargs, keywords);
732
733 PERF_TIMER("NativeCursor::fetch");
734 py::list values;
735 SQLRETURN ret = FetchSingleRow(plan->handle, values, plan->charEncoding,Lines 746-758 746 throw py::stop_iteration();
747 return py::none();
748 }
749 if (plan->nextIndex == std::numeric_limits<Py_ssize_t>::max())
! 750 return finish_python(self, mode, values, ret, false);
751 py::int_ next(plan->nextIndex + 1);
752 py::int_ rowNumber(plan->nextIndex);
753 if (!current(self, context, state, *plan, rt))
! 754 return finish_python(self, mode, values, ret, false);
755 auto counterNames = py::reinterpret_borrow<py::tuple>(rt[5]);
756 set_counter(*plan, counterNames[0], next);
757 set_counter(*plan, counterNames[1], rowNumber);
758 set_counter(*plan, counterNames[2], next);Lines 757-765 757 set_counter(*plan, counterNames[1], rowNumber);
758 set_counter(*plan, counterNames[2], next);
759 ++plan->nextIndex;
760 if (!current(self, context, state, *plan, rt))
! 761 return finish_python(self, mode, values, ret, true);
762
763 if (mode == Mode::Value) {
764 // Decode every column, not just column zero. Eligible plans have no
765 // converters, UUID processing or customized Row/fetchone behavior.Lines 791-799 791 PyObject* keywords) {
792 try {
793 auto* context = reinterpret_cast<Context*>(owner);
794 if (!context->functions)
! 795 throw std::runtime_error("Native fetch context is not initialized");
796 if (context->runtime && count > 0 &&
797 PyObject_TypeCheck(args[0], reinterpret_cast<PyTypeObject*>(context->cursorType)) &&
798 (mode == Mode::Many ||
799 (count == 1 && (!keywords || PyTuple_GET_SIZE(keywords) == 0)))) {Lines 821-852 821 auto* context = reinterpret_cast<Context*>(owner);
822 if (context->disabled)
823 throw std::runtime_error("Native fetch installation is disabled after incomplete rollback");
824 if (context->cursorType || context->active)
! 825 throw std::runtime_error("Native fetch context is already installed");
826 PyObject* cursorType;
827 PyObject* functions;
828 if (!PyArg_ParseTuple(args, "OO", &cursorType, &functions))
! 829 return nullptr;
830 if (!PyType_CheckExact(cursorType) || !PyTuple_CheckExact(functions) ||
831 PyTuple_GET_SIZE(functions) != 4)
! 832 throw py::type_error("Invalid native fetch installation");
833 auto bases = steal(PyObject_GetAttrString(cursorType, "__bases__"));
834 if (!bases)
! 835 throw py::error_already_set();
836 if (!PyTuple_CheckExact(bases.ptr()) || PyTuple_GET_SIZE(bases.ptr()) != 1 ||
837 PyTuple_GET_ITEM(bases.ptr(), 0) != reinterpret_cast<PyObject*>(&PyBaseObject_Type))
! 838 throw py::type_error("Native fetch requires a plain Python cursor");
839 for (Py_ssize_t i = 0; i < 4; ++i) {
840 if (!PyFunction_Check(PyTuple_GET_ITEM(functions, i)))
! 841 throw py::type_error("Native fetch fallbacks must be Python functions");
842 }
843 auto attributes = steal(PyType_GetDict(reinterpret_cast<PyTypeObject*>(cursorType)));
844 if (!attributes)
! 845 throw py::error_already_set();
846 auto* descriptor = exact_item(attributes.ptr(), "__dict__");
847 if (!descriptor || Py_TYPE(descriptor) != &PyGetSetDescr_Type)
! 848 throw py::type_error("Native fetch requires an ordinary cursor dictionary");
849 context->cursorType = Py_NewRef(cursorType);
850 context->functions = Py_NewRef(functions);
851 context->dictDescriptor = Py_NewRef(descriptor);
852 Py_RETURN_NONE;Lines 873-883 873 for (Py_ssize_t field = 4; field <= 5; ++field) {
874 auto* names = PyTuple_GET_ITEM(runtime, field);
875 const Py_ssize_t count = field == 4 ? 5 : 3;
876 if (!PyTuple_CheckExact(names) || PyTuple_GET_SIZE(names) != count) {
! 877 PyErr_SetString(PyExc_TypeError, "Invalid native fetch field names");
! 878 return nullptr;
! 879 }
880 for (Py_ssize_t i = 0; i < count; ++i) {
881 auto* name = PyTuple_GET_ITEM(names, i);
882 if (!PyUnicode_CheckExact(name) ||
883 PyUnicode_CompareWithASCIIString(Lines 881-892 881 auto* name = PyTuple_GET_ITEM(names, i);
882 if (!PyUnicode_CheckExact(name) ||
883 PyUnicode_CompareWithASCIIString(
884 name, field == 4 ? rowNames[i] : counterNames[i]) != 0) {
! 885 if (!PyErr_Occurred())
! 886 PyErr_SetString(PyExc_TypeError, "Invalid native fetch field name");
! 887 return nullptr;
! 888 }
889 }
890 }
891 context->runtime = Py_NewRef(runtime);
892 Py_RETURN_NONE;Lines 894-904 894
895 inline PyObject* abort_install(PyObject* owner, PyObject*) {
896 auto* context = reinterpret_cast<Context*>(owner);
897 if (context->disabled || context->runtime || context->active) {
! 898 PyErr_SetString(PyExc_RuntimeError, "Cannot abort an active native fetch context");
! 899 return nullptr;
! 900 }
901 auto* cursorType = context->cursorType;
902 auto* functions = context->functions;
903 auto* descriptor = context->dictDescriptor;
904 context->cursorType = context->functions = context->dictDescriptor = nullptr;Lines 910-920 910
911 inline PyObject* disable_install(PyObject* owner, PyObject*) {
912 auto* context = reinterpret_cast<Context*>(owner);
913 if (context->runtime || context->active) {
! 914 PyErr_SetString(PyExc_RuntimeError, "Cannot disable an active native fetch context");
! 915 return nullptr;
! 916 }
917 // Keep every forwarding owner: even descriptors restored on the class may
918 // have been saved elsewhere. Unpublished entries can never create State.
919 context->disabled = true;
920 Py_RETURN_NONE;Lines 991-999 991 auto* context = reinterpret_cast<Context*>(owner);
992 if (reinterpret_cast<PyObject*>(Py_TYPE(receiver)) == context->cursorType) {
993 auto dict = instance_dict(receiver);
994 if (!dict)
! 995 throw py::error_already_set();
996 auto* value = state_entry(dict.ptr()).value;
997 if (value && reinterpret_cast<PyObject*>(Py_TYPE(value)) == context->stateType) {
998 auto stateOwner = py::reinterpret_borrow<py::object>(value);
999 auto* state = reinterpret_cast<State*>(value);Lines 1003-1013 1003 }
1004 }
1005 Py_RETURN_NONE;
1006 } catch (...) {
! 1007 py::detail::try_translate_exceptions();
! 1008 return nullptr;
! 1009 }
1010 }
1011
1012 inline void bind(py::module_& m) {
1013 #ifdef _MSC_VERLines 1048-1056 1048 Py_TPFLAGS_IMMUTABLETYPE,
1049 stateSlots};
1050 auto stateType = steal(PyType_FromSpec(&stateSpec));
1051 if (!stateType)
! 1052 throw py::error_already_set();
1053 static PyType_Slot contextSlots[] = {
1054 {Py_tp_new, reinterpret_cast<void*>(internal_new)},
1055 {Py_tp_dealloc, reinterpret_cast<void*>(dealloc<Context, context_clear>)},
1056 {Py_tp_traverse, reinterpret_cast<void*>(context_traverse)},Lines 1062-1079 1062 Py_TPFLAGS_IMMUTABLETYPE,
1063 contextSlots};
1064 auto contextType = steal(PyType_FromSpec(&contextSpec));
1065 if (!contextType)
! 1066 throw py::error_already_set();
1067 auto contextOwner = steal(PyType_GenericAlloc(
1068 reinterpret_cast<PyTypeObject*>(contextType.ptr()), 0));
1069 if (!contextOwner)
! 1070 throw py::error_already_set();
1071 auto* context = reinterpret_cast<Context*>(contextOwner.ptr());
1072 context->stateType = stateType.release().ptr();
1073 context->stateKey = PyUnicode_InternFromString("_native_fetch_state");
1074 if (!context->stateKey)
! 1075 throw py::error_already_set();
1076 m.attr("_native_fetch_schema") = 2;
1077 #ifdef MSSQL_NATIVE_FETCH_TEST_FAULTS
1078 m.attr("_native_fetch_test_fault_schema") = 1;
1079 #endifLines 1081-1092 1081 py::tuple entries(4);
1082 for (Py_ssize_t i = 0; i < 4; ++i) {
1083 py::object function = steal(PyCFunction_NewEx(&methods[i], contextOwner.ptr(), nullptr));
1084 if (!function)
! 1085 throw py::error_already_set();
1086 py::object descriptor = steal(PyInstanceMethod_New(function.ptr()));
1087 if (!descriptor)
! 1088 throw py::error_already_set();
1089 PyTuple_SET_ITEM(entries.ptr(), i, descriptor.release().ptr());
1090 }
1091 m.attr("_native_fetch_methods") = std::move(entries);
1092 static PyMethodDef controls[] = {Lines 1103-1111 1103 };
1104 for (auto* method = controls; method->ml_name; ++method) {
1105 auto function = steal(PyCFunction_NewEx(method, contextOwner.ptr(), nullptr));
1106 if (!function)
! 1107 throw py::error_already_set();
1108 m.attr(method->ml_name) = std::move(function);
1109 }
1110
1111 auto observer = std::make_unique<Observer>();Lines 1109-1141 1109 }
1110
1111 auto observer = std::make_unique<Observer>();
1112 auto unavailable = [&m]() {
! 1113 PyErr_Clear();
! 1114 if (PyErr_WarnEx(PyExc_RuntimeWarning,
! 1115 "Native fetch prototype disabled: CPython watcher slots unavailable",
! 1116 1) < 0)
! 1117 throw py::error_already_set();
! 1118 m.attr("_fetch_observer") = py::none();
! 1119 };
1120 observer->dict = PyDict_AddWatcher(dict_changed);
1121 if (observer->dict < 0) {
! 1122 unavailable();
! 1123 return;
! 1124 }
1125 observer->type = PyType_AddWatcher(type_changed);
1126 if (observer->type < 0) {
! 1127 PyDict_ClearWatcher(observer->dict);
! 1128 unavailable();
! 1129 return;
! 1130 }
1131 observer->function = PyFunction_AddWatcher(function_changed);
1132 if (observer->function < 0) {
! 1133 PyType_ClearWatcher(observer->type);
! 1134 PyDict_ClearWatcher(observer->dict);
! 1135 unavailable();
! 1136 return;
! 1137 }
1138 // One registration per native-module lifetime. The callbacks retain no
1139 // watched objects; CPython removes registrations at interpreter teardown.
1140 m.attr("_fetch_observer") = py::capsule(
1141 observer.release(), "mssql_python.native_fetch.Observer", [](void* pointer) {Lines 1146-1165 1146 const py::tuple& dicts, const py::tuple& types) {
1147 auto* observer = static_cast<Observer*>(
1148 PyCapsule_GetPointer(capsule.ptr(), "mssql_python.native_fetch.Observer"));
1149 if (!observer)
! 1150 throw py::error_already_set();
1151 for (auto dict : dicts) {
1152 if (PyDict_Watch(observer->dict, dict.ptr()) < 0)
! 1153 throw py::error_already_set();
1154 }
1155 for (auto type : types) {
1156 if (!PyType_Check(type.ptr()))
! 1157 throw py::type_error("Expected a type dependency");
1158 if (!(PyType_GetFlags(reinterpret_cast<PyTypeObject*>(type.ptr())) &
1159 Py_TPFLAGS_IMMUTABLETYPE) &&
1160 PyType_Watch(observer->type, type.ptr()) < 0)
! 1161 throw py::error_already_set();
1162 }
1163 });
1164 }mssql_python/pybind/row_factory.hppLines 16-24 16 const py::tuple& attribute_names) {
17 auto* row_type = reinterpret_cast<PyTypeObject*>(row_class.ptr());
18 py::object row = steal(row_type->tp_alloc(row_type, 0));
19 if (!row)
! 20 throw py::error_already_set();
21 PyObject* fields[] = {values.ptr(), column_map.ptr(), cursor.ptr(),
22 column_map_lower.ptr(), column_names.ptr()};
23 for (Py_ssize_t i = 0; i < 5; ++i) {
24 if (SetAttribute(row.ptr(), PyTuple_GET_ITEM(attribute_names.ptr(), i),Lines 22-30 22 column_map_lower.ptr(), column_names.ptr()};
23 for (Py_ssize_t i = 0; i < 5; ++i) {
24 if (SetAttribute(row.ptr(), PyTuple_GET_ITEM(attribute_names.ptr(), i),
25 fields[i]) < 0)
! 26 throw py::error_already_set();
27 }
28 return row;
29 }📋 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.6%
mssql_python.pybind.logger_bridge.hpp: 70.8%
mssql_python._native_fetch.py: 77.8%
mssql_python.pybind.ddbc_bindings.cpp: 80.4%
mssql_python.pybind.connection.connection_pool.cpp: 82.3%
mssql_python.pybind.connection.connection.cpp: 83.1%
mssql_python.logging.py: 86.2%
mssql_python.pybind.native_fetch.hpp: 88.7%🔗 Quick Links
|


Work Item / Issue Reference
N/A; the work item above is the sole reference.
Summary
Reuse cursor-scoped native state for eligible one-row fetching while retaining the existing Python fallback behavior.
fetchone(), eligiblefetchmany(1),fetchval(), and iteration on supported GIL-enabled CPython versions. Unsupported interpreters retain the Python methods.SQLGetDatametadata, and reuse successful column unbinding.This is the complete 10-file Source05 delta against
9424705251baf8ded41a8a4968216b1fd9b00929, without later hybrid/converter redesigns or benchmark artifacts. The recovered sources match the measured Source05 archive; no current-base semantic adaptation was needed. There is no public API change.Validation
Fresh Linux x86_64 builds used CPython 3.13.15, SQL Server 2022 CU22, and Release flags
-O3 -DNDEBUG. Profiling-OFF, standard-ON, and dedicated fault builds all compiled and imported successfully, with native-module paths and binary hashes checked.python -m pre_commit run black-check --all-files --hook-stage pre-pushandgit diff --check: passed.python -m pytest -q -ra --tb=short tests/test_native_fetch_dispatch.py tests/test_single_row_pipeline.py: OFF 216 passed, 16 expected skips; standard ON 225 passed, 7 dedicated-fault skips.python -m pytest -q -ra --tb=short tests/test_native_fetch_dispatch.py -k redesign: dedicated fault build 30 passed, 71 deselected, including all seven failure-control cases.python -m pytest -q -ra --tb=short -v --maxfail=3 --timeout=120: 5,393 passed, 139 skipped, 3 failed, 42 stress tests deselected before the configured failure limit.python -m pytest -q -ra --tb=short @/validation/off-remaining-nodeids.txt --timeout=120: executed exactly the remaining 628 collected OFF nodes, with no exclusions: 593 passed, 33 skipped, 2 failed.The two nonoverlapping OFF suite segments account for all 6,163 selected tests: 5,986 passed, 172 skipped, 5 failed. The full suite is not green. All five failures are unchanged
RunnerTestsintest_038_mssql_odbc_daily_validation.py: Bash rejects CRLF ineng/scripts/run-mssql-odbc-tests.sh. Each failure also reproduced on freshly built frozen main; the unrelated script is unchanged. An earlier full-suite attempt was interrupted after SQL memory exhaustion; its narrowed failures passed on both main and Source05 after increasing only the test server's memory allocation.Windows, macOS, ARM64, other Python versions, and sanitizer/allocator-exhaustion coverage were not run locally.
Performance scope
Historical matched Linux diagnostics of these Source05 sources used approximately 42.2% less elapsed time for repeated
fetchone()and 74.1% less for repeatedfetchmany(1)than pinned main. Converter fetching was mixed and 1.1% slower at the paired median; pyodbc remained faster in all retained core-operation pairs. These narrow historical diagnostics are not a new benchmark run or all-API qualification. Converter, pyodbc, and full performance-qualification requirements remain unmet; this PR does not claim merge readiness.