Skip to content

PERF: Speed up row-to-dictionary conversion and add CI coverage - #847

Merged
Jahnvi Thakkar (jahnvi480) merged 3 commits into
mainfrom
jahnvi/rowmapping-speedup
Nov 4, 2026
Merged

Jahnvi Thakkar (jahnvi480) merged 3 commits into
mainfrom
jahnvi/rowmapping-speedup

Conversation

@jahnvi480

@jahnvi480 Jahnvi Thakkar (jahnvi480) commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Work Item / Issue Reference

AB#49468


Summary

This pull request introduces a new benchmark scenario for row-to-dictionary conversion and adds supporting infrastructure for efficient mapping in the codebase. The main changes include the addition of the row_to_dict workload, updates to validation logic to accommodate its unique characteristics, and the introduction of new C++ and Python classes to optimize and track row mapping schema. These changes enhance profiling coverage and prepare the system for more efficient dictionary mapping in result rows.

Profiler Benchmark Enhancements:

  • Added a new benchmark scenario, row_to_dict, which measures the warm-only conversion of 10,000 fetched rows with 32 integer columns to dictionaries using dict(row._mapping). The benchmark is described in detail in the README.md and implemented in workloads.py. This scenario specifically times only the conversion step, not the fetch or metadata preparation, and allows for empty native diagnostics.

  • Updated validation logic in both the controller and report modules to permit empty native (cpp) diagnostics only for the new row_to_dict scenario, and refactored phase validation into a shared function validate_phases.

Row Mapping Infrastructure:

  • Introduced a new _ColumnNames class in row.py to encapsulate result column names, with validation and deduplication logic for mapping use. This class is now used in both sync and async cursor implementations to store column names, replacing plain tuples.

  • Added a new C++ header, row_mapping_cache.hpp, which implements a cache and watcher infrastructure for row mapping schema, supporting efficient dictionary mapping and change tracking in CPython 3.12+.

  • Exposed a new C++ binding _make_row_mapping_base and included the new row_mapping.hpp header in the bindings module to support future extensions for fast row mapping.

Share lazy result-shape metadata and add guarded native mapping iteration and lookup while preserving live-view behavior. Add one warm row-to-dictionary workload to the existing performance CI registry with focused contract coverage.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Remove overlapping matrices while retaining native ownership, invalidation, dispatch, custom behavior and mapping semantics. Extend existing controls only for measured assertion gaps; production and the row_to_dict workload stay unchanged.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings October 16, 2026 07:48
@github-actions github-actions Bot added the pr-size: large Substantial code update label Oct 16, 2026
@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

PR Performance Report

✅ Performance improved

1 database task consistently improved across 2 measured environments. No consistent slowdowns were detected.

1 IMPROVEMENT 0 SLOWDOWNS 2/2 ENVIRONMENTS

Signal fingerprint

Database task Unix / SQL Server 2022 Unix / SQL Server 2025
10,000 rows x 32 columns / dict(row._mapping) (warm) 84.0% faster 82.6% faster

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

Measured timings
Environment Database task Before After Change
Unix / SQL Server 2022 10,000 rows x 32 columns / dict(row._mapping) (warm) 193.673 ms 31.152 ms -84.0%
Unix / SQL Server 2025 10,000 rows x 32 columns / dict(row._mapping) (warm) 187.596 ms 32.776 ms -82.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

10,000 rows x 32 columns / dict(row._mapping) (warm): no measured phase delta.

Unix / SQL Server 2025

10,000 rows x 32 columns / dict(row._mapping) (warm): no measured phase delta.

All database tasks and timings

Unix / SQL Server 2022

Database task Before After Paired change Result
Connection opening 10.287 ms 10.040 ms -2.9% no signal
SELECT queries 0.996 ms 0.970 ms -3.0% no signal
Row insertion 32.411 ms 32.472 ms -0.4% no signal
Executemany inserts 156.687 ms 157.145 ms -0.0% no signal
Fetch-all queries 119.941 ms 119.535 ms -0.3% no signal
Row-by-row fetching 14.384 ms 14.224 ms +1.0% no signal
Batched row fetching 115.069 ms 116.065 ms +0.4% no signal
Transaction commit and rollback 106.705 ms 106.467 ms -0.4% no signal
Arrow row fetching 89.078 ms 88.317 ms -1.3% no signal
100,000-row insertion 425.150 ms 427.913 ms +0.4% no signal
Row fetching in batches of 100 120.192 ms 121.976 ms +1.2% no signal
Row fetching in batches of 10,000 125.060 ms 123.788 ms -2.8% no signal
Repeated positional queries 31.949 ms 31.933 ms +0.3% no signal
Repeated named-parameter queries 34.258 ms 34.272 ms +0.0% no signal
Legacy 100,000-row insertion 347.500 ms 345.513 ms -0.7% no signal
Insertion with explicit input sizes 475.068 ms 471.505 ms -0.6% no signal
Joined aggregation queries 179.895 ms 177.164 ms -1.5% no signal
Large joined-result fetching 185.025 ms 183.633 ms -1.4% no signal
1.2-million-row fetching 3410.020 ms 3448.464 ms +1.5% no signal
Common table expression queries 5.265 ms 5.279 ms +0.7% no signal
256 KiB VARCHAR(MAX) / fetchall() 1.340 ms 1.601 ms +22.8% no signal
10,000 scalar values / fetchval() (debug disabled) 106.623 ms 105.992 ms +0.7% no signal
10,000 rows x 32 columns / dict(row._mapping) (warm) 193.673 ms 31.152 ms -84.0% consistent improvement

Unix / SQL Server 2025

Database task Before After Paired change Result
Connection opening 96.004 ms 95.280 ms -0.2% no signal
SELECT queries 0.947 ms 0.970 ms +2.4% no signal
Row insertion 30.804 ms 30.848 ms +0.4% no signal
Executemany inserts 128.613 ms 130.566 ms +1.2% no signal
Fetch-all queries 115.007 ms 115.019 ms +0.1% no signal
Row-by-row fetching 12.582 ms 12.573 ms -0.8% no signal
Batched row fetching 108.240 ms 108.818 ms +0.9% no signal
Transaction commit and rollback 99.367 ms 98.795 ms -0.2% no signal
Arrow row fetching 81.976 ms 82.822 ms +1.8% no signal
100,000-row insertion 381.688 ms 382.446 ms -0.3% no signal
Row fetching in batches of 100 105.690 ms 105.177 ms -0.4% no signal
Row fetching in batches of 10,000 115.626 ms 130.830 ms -0.1% no signal
Repeated positional queries 29.427 ms 29.469 ms +0.6% no signal
Repeated named-parameter queries 31.807 ms 31.863 ms -0.2% no signal
Legacy 100,000-row insertion 301.205 ms 300.768 ms -0.0% no signal
Insertion with explicit input sizes 420.214 ms 422.941 ms +1.1% no signal
Joined aggregation queries 160.886 ms 160.442 ms -0.4% no signal
Large joined-result fetching 177.936 ms 176.174 ms -0.5% no signal
1.2-million-row fetching 3419.762 ms 3411.432 ms +0.0% no signal
Common table expression queries 5.213 ms 5.094 ms -2.0% no signal
256 KiB VARCHAR(MAX) / fetchall() 1.363 ms 1.395 ms +2.2% no signal
10,000 scalar values / fetchval() (debug disabled) 100.361 ms 100.579 ms -0.2% no signal
10,000 rows x 32 columns / dict(row._mapping) (warm) 187.596 ms 32.776 ms -82.6% consistent improvement
Build and measurement details

ADO build 189061

PR head: b6190a5257b1b2ad0d592b242414bc8f3161ffb3
Base: 5fd233198989a000143b32718adf2d9d2a103b13
Measured merge: 9cdd098b146a6383bd57fe211eebf34e7d3d7ba0

  • 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.5035.5; 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.

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.

🔵 Needs a closer look

Native ownership and invalidation changes require human review, while cross-platform validation and broader performance qualification remain incomplete.

0 open findings

What changed in this PR

Optimizes dict(row._mapping) through shared lazy metadata and guarded native access, with focused compatibility tests and performance-CI coverage.

Changes:

  • Adds native mapping iteration, lookup, and cache invalidation.
  • Shares column metadata across synchronous and asynchronous cursor rows.
  • Adds one warm-conversion benchmark, expanding the registry to 23 workloads.
File Description
tests/​test_row_mapping_metadata.py Tests mapping semantics, mutation guards, and lifetimes.
tests/​test_036_profiler_ci.py Tests benchmark timing boundaries and diagnostics validation.
mssql_python/​row.py Adds shared metadata and native-backed mapping views.
mssql_python/​pybind/​row_mapping.hpp Implements guarded native mapping access.
mssql_python/​pybind/​row_mapping_cache.hpp Adds watched schemas and ownership handling.
mssql_python/​pybind/​ddbc_bindings.cpp Exposes the native mapping factory.
mssql_python/​cursor.py Shares lazy column metadata across fetched rows.
mssql_python/​async_query/​async_cursor.py Uses shared metadata for asynchronous results.
eng/​profiler_benchmarks/​workloads.py Adds the warm row-to-dictionary workload.
eng/​profiler_benchmarks/​report.py Validates and reports the new workload.
eng/​profiler_benchmarks/​README.md Documents timing boundaries and qualification limits.
eng/​profiler_benchmarks/​controller.py Allows empty native diagnostics only for the new workload.

🧠 Review effort: Balanced


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

@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

📊 Code Coverage Report

🔥 Diff Coverage

86%


🎯 Overall Coverage

85%


📈 Total Lines Covered: 10471 out of 12299
📁 Project: mssql-python


Diff Coverage

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

  • mssql_python/async_query/async_cursor.py (100%)
  • mssql_python/cursor.py (100%)
  • mssql_python/pybind/row_mapping.hpp (86.8%): Missing lines 144,167,170,174,181,185-187,238,242,246,254,260-262,275,351,386,403,406,412-415,420,422,426,435-436,441,444,450,458,460,467,469,471,475,477,488,490,492,494,497,585-587,595,612,619,626,633,637,646,649,655,657-675,737-739,743-745,752-754,756-759,769-771,775,789,805,809,875
  • mssql_python/pybind/row_mapping_cache.hpp (81.5%): Missing lines 51,64-66,72,77-82,89-92,97,101,104,109,113,115,120-123,131,189-191,194-196,205,239
  • mssql_python/row.py (100%)

Summary

  • Total: 955 lines
  • Missing: 130 lines
  • Coverage: 86%

mssql_python/pybind/row_mapping.hpp

Lines 140-148

  140     PyMemberDef* member = reinterpret_cast<PyMemberDescrObject*>(descriptor)->d_member;
  141     if (member->type != T_OBJECT_EX || member->offset < static_cast<Py_ssize_t>(sizeof(PyObject)) ||
  142         member->offset > type->tp_basicsize - static_cast<Py_ssize_t>(sizeof(PyObject*)) ||
  143         member->offset % static_cast<Py_ssize_t>(alignof(PyObject*)) != 0)
! 144         return -1;
  145     return member->offset;
  146 }
  147 
  148 inline bool valid_iterator_type(PyObject* defaults) {

Lines 163-178

  163         PyErr_SetString(PyExc_TypeError, "Invalid RowMapping configuration");
  164         return false;
  165     }
  166     if (!valid_iterator_type(defaults))
! 167         return false;
  168     unsigned long version = PyLong_AsUnsignedLong(PyTuple_GET_ITEM(defaults, ROW_VERSION));
  169     if (version == static_cast<unsigned long>(-1) && PyErr_Occurred())
! 170         return false;
  171     unsigned long names_version = PyLong_AsUnsignedLong(
  172         PyTuple_GET_ITEM(defaults, NAMES_VERSION));
  173     if (names_version == static_cast<unsigned long>(-1) && PyErr_Occurred())
! 174         return false;
  175     RowMappingCache::Watcher* watcher = nullptr;
  176     PyObject* owner = PyTuple_GET_ITEM(defaults, WATCHER_OWNER);
  177     if (owner != Py_None) {
  178         watcher = static_cast<RowMappingCache::Watcher*>(

Lines 177-191

  177     if (owner != Py_None) {
  178         watcher = static_cast<RowMappingCache::Watcher*>(
  179             PyCapsule_GetPointer(owner, "mssql.RowMappingWatcher"));
  180         if (!watcher)
! 181             return false;
  182     }
  183     PyObject* members = PyTuple_GET_ITEM(defaults, ROW_MEMBERS);
  184     if (!PyTuple_CheckExact(members) || PyTuple_GET_SIZE(members) != ROW_FIELD_COUNT) {
! 185         PyErr_SetString(PyExc_TypeError, "Invalid RowMapping member configuration");
! 186         return false;
! 187     }
  188     access.row_version = static_cast<unsigned int>(version);
  189     access.names_version = static_cast<unsigned int>(names_version);
  190     access.watcher = watcher;
  191     auto* row_type = reinterpret_cast<PyTypeObject*>(PyTuple_GET_ITEM(defaults, ROW_TYPE));

Lines 234-250

  234             Py_TPFLAGS_DISALLOW_INSTANTIATION, slots,
  235     };
  236     py::object type = steal(PyType_FromSpec(&spec));
  237     if (!type)
! 238         throw py::error_already_set();
  239     auto* native_type = reinterpret_cast<PyTypeObject*>(type.ptr());
  240     py::object owner = steal(native_type->tp_alloc(native_type, 0));
  241     if (!owner)
! 242         throw py::error_already_set();
  243     auto* configuration = reinterpret_cast<Configuration*>(owner.ptr());
  244     configuration->defaults = Py_NewRef(defaults.ptr());
  245     if (!read_configuration(defaults.ptr(), configuration->access))
! 246         throw py::error_already_set();
  247     return owner;
  248 }
  249 
  250 inline PyObject* create(PyTypeObject* type, PyObject*, PyObject*) {

Lines 250-258

  250 inline PyObject* create(PyTypeObject* type, PyObject*, PyObject*) {
  251     py::object configuration = steal(PyObject_GetAttrString(
  252         reinterpret_cast<PyObject*>(type), "_row_mapping_defaults"));
  253     if (!configuration)
! 254         return nullptr;
  255     py::object defaults;
  256     AccessConfiguration access{};
  257     if (is_configuration(configuration.ptr())) {
  258         auto* native = reinterpret_cast<Configuration*>(configuration.ptr());

Lines 256-266

  256     AccessConfiguration access{};
  257     if (is_configuration(configuration.ptr())) {
  258         auto* native = reinterpret_cast<Configuration*>(configuration.ptr());
  259         if (!native->defaults) {
! 260             PyErr_SetString(PyExc_TypeError, "Invalid RowMapping configuration");
! 261             return nullptr;
! 262         }
  263         defaults = borrow(native->defaults);
  264         access = native->access;
  265     } else {
  266         defaults = std::move(configuration);

Lines 271-279

  271     if (!valid_iterator_type(defaults.ptr()))
  272         return nullptr;
  273     auto* self = reinterpret_cast<View*>(type->tp_alloc(type, 0));
  274     if (!self)
! 275         return nullptr;
  276     self->defaults = defaults.release().ptr();
  277     self->configuration = configuration.release().ptr();
  278     self->row_version = access.row_version;
  279     self->names_version = access.names_version;

Lines 347-355

  347     py::object names;
  348     if (plain_row) {
  349         names = row_field(self, row, NAMES, ATTR_NAMES);
  350         if (!names)
! 351             return {};
  352     }
  353     if (!plain_row || names.is_none()) {
  354         names = steal(PyObject_CallNoArgs(hook.ptr()));
  355     }

Lines 382-390

  382         Py_TYPE(names)->tp_version_tag != self->names_version)
  383         return 0;
  384     if ((!Py_TYPE(row)->tp_dictoffset && !(Py_TYPE(row)->tp_flags & Py_TPFLAGS_MANAGED_DICT)) ||
  385         (!Py_TYPE(names)->tp_dictoffset && !(Py_TYPE(names)->tp_flags & Py_TPFLAGS_MANAGED_DICT)))
! 386         return 0;
  387     for (int field = 0; field < ROW_FIELD_COUNT; ++field)
  388         if (self->row_offsets[field] < 0)
  389             return 0;
  390     PyObject* values = stored_field(self, row, VALUES);

Lines 399-410

  399     // Materialize only the ordinary instance dictionary, never values/getset storage.
  400     // Its stable pointer also detects wholesale __dict__ replacement without a watcher event.
  401     py::object row_dictionary = steal(PyObject_GenericGetDict(row, nullptr));
  402     if (!row_dictionary)
! 403         return -1;
  404     py::object names_dictionary = steal(PyObject_GenericGetDict(names, nullptr));
  405     if (!names_dictionary)
! 406         return -1;
  407     if (!builtin_keys(row_dictionary.ptr()) || !builtin_keys(names_dictionary.ptr()))
  408         return 0;
  409     PyObject** row_pointer = _PyObject_GetDictPtr(row);
  410     PyObject** names_pointer = _PyObject_GetDictPtr(names);

Lines 408-430

  408         return 0;
  409     PyObject** row_pointer = _PyObject_GetDictPtr(row);
  410     PyObject** names_pointer = _PyObject_GetDictPtr(names);
  411     if (!row_pointer || !names_pointer) {
! 412         if (PyErr_Occurred())
! 413             return -1;
! 414         return 0;
! 415     }
  416     if (PyDict_Watch(self->watcher->id, columns) < 0 ||
  417         PyDict_Watch(self->watcher->id, unique) < 0 ||
  418         PyDict_Watch(self->watcher->id, row_dictionary.ptr()) < 0 ||
  419         PyDict_Watch(self->watcher->id, names_dictionary.ptr()) < 0)
! 420         return -1;
  421     if (!builtin_keys(names_dictionary.ptr()))
! 422         return 0;
  423     PyObject* cached = PyDict_GetItemWithError(
  424         names_dictionary.ptr(), PyTuple_GET_ITEM(self->defaults, ATTR_PREPARED));
  425     if (!cached && PyErr_Occurred())
! 426         return -1;
  427     auto* schema_class = reinterpret_cast<PyTypeObject*>(
  428         PyTuple_GET_ITEM(self->defaults, SCHEMA_TYPE));
  429     py::object schema_owner;
  430     Schema* schema = nullptr;

Lines 431-448

  431     if (cached && Py_IS_TYPE(cached, schema_class)) {
  432         schema_owner = borrow(cached);
  433         schema = reinterpret_cast<Schema*>(cached);
  434     } else if (cached && cached != Py_None) {
! 435         return 0;
! 436     }
  437     if (!schema || schema->epoch != self->watcher->epoch ||
  438         schema->columns != columns || schema->unique != unique) {
  439         py::object indices = steal(PyDict_New());
  440         if (!indices)
! 441             return -1;
  442         schema_owner = steal(schema_class->tp_alloc(schema_class, 0));
  443         if (!schema_owner)
! 444             return -1;
  445         schema = reinterpret_cast<Schema*>(schema_owner.ptr());
  446         std::uint64_t epoch = self->watcher->epoch;
  447         if (!builtin_keys(columns, true) || !builtin_keys(unique))
  448             return 0;

Lines 446-454

  446         std::uint64_t epoch = self->watcher->epoch;
  447         if (!builtin_keys(columns, true) || !builtin_keys(unique))
  448             return 0;
  449         if (allocate_entries(schema, PyDict_Size(unique)) < 0)
! 450             return -1;
  451         Py_ssize_t position = 0;
  452         PyObject *key, *unused;
  453         while (PyDict_Next(unique, &position, &key, &unused)) {
  454             PyObject* index = PyDict_GetItemWithError(columns, key);

Lines 454-464

  454             PyObject* index = PyDict_GetItemWithError(columns, key);
  455             if (!index)
  456                 return PyErr_Occurred() ? -1 : 0;
  457             if (PyDict_SetItem(indices.ptr(), key, index) < 0)
! 458                 return -1;
  459             if (append_entry(schema, key, index) < 0)
! 460                 return -1;
  461         }
  462         schema->columns = Py_NewRef(columns);
  463         schema->unique = Py_NewRef(unique);
  464         schema->indices = indices.release().ptr();

Lines 463-481

  463         schema->unique = Py_NewRef(unique);
  464         schema->indices = indices.release().ptr();
  465         schema->watcher_owner = Py_NewRef(PyTuple_GET_ITEM(self->defaults, WATCHER_OWNER));
  466         if (PyDict_Watch(self->watcher->id, schema->indices) < 0)
! 467             return -1;
  468         if (self->watcher->epoch != epoch)
! 469             return 0;
  470         if (!builtin_keys(names_dictionary.ptr()))
! 471             return 0;
  472         // This single watched write advances the epoch. Any additional mutation deopts.
  473         if (PyDict_SetItem(names_dictionary.ptr(),
  474                            PyTuple_GET_ITEM(self->defaults, ATTR_PREPARED), schema_owner.ptr()) < 0)
! 475             return -1;
  476         if (!self->watcher->enabled || self->watcher->epoch != epoch + 1)
! 477             return 0;
  478         schema->epoch = self->watcher->epoch;
  479         ++self->schema_builds;
  480     }
  481     // Allocations above may run GC/finalizers: seal only after rechecking all dependencies.

Lines 484-501

  484         Py_TYPE(self)->tp_version_tag != self->view_version ||
  485         Py_TYPE(names)->tp_version_tag != self->names_version ||
  486         *row_pointer != row_dictionary.ptr() || *names_pointer != names_dictionary.ptr() ||
  487         stored_field(self, row, NAMES) != names || stored_field(self, row, COLUMNS) != columns)
! 488         return 0;
  489     if (!builtin_keys(row_dictionary.ptr()) || !builtin_keys(names_dictionary.ptr()))
! 490         return 0;
  491     if (PyDict_GetItemWithError(row_dictionary.ptr(), PyTuple_GET_ITEM(self->defaults, ATTR_KEYS)))
! 492         return 0;
  493     if (PyErr_Occurred())
! 494         return -1;
  495     if (PyDict_GetItemWithError(names_dictionary.ptr(),
  496                                PyTuple_GET_ITEM(self->defaults, ATTR_UNIQUE)) != unique)
! 497         return PyErr_Occurred() ? -1 : 0;
  498     self->row_dict_pointer = row_pointer;
  499     self->names_dict_pointer = names_pointer;
  500     self->prepared_row = row_owner.release().ptr();
  501     self->prepared_names = names_owner.release().ptr();

Lines 581-591

  581 }
  582 
  583 inline PyObject* subscript(View* self, PyObject* key) {
  584     if (!self->defaults) {
! 585         PyErr_SetString(PyExc_AttributeError, "RowMapping has no row");
! 586         return nullptr;
! 587     }
  588     PyObject* prepared_result;
  589     if (prepared_subscript(self, key, &prepared_result))
  590         return prepared_result;
  591     ++self->slow_lookups;

Lines 591-599

  591     ++self->slow_lookups;
  592     if (!PyUnicode_Check(key)) {
  593         int is_string = PyObject_IsInstance(key, reinterpret_cast<PyObject*>(&PyUnicode_Type));
  594         if (is_string < 0)
! 595             return nullptr;
  596         if (!is_string)
  597             return missing_key(key);
  598     }
  599     py::object row = observed_row(self);

Lines 608-616

  608     if (PyUnicode_CheckExact(key) &&
  609         reinterpret_cast<PyObject*>(Py_TYPE(names.ptr())) == names_type) {
  610         py::object unique = get_unique(self, names.ptr());
  611         if (!unique)
! 612             return nullptr;
  613         contains = PySequence_Contains(unique.ptr() == Py_False ? names.ptr() : unique.ptr(), key);
  614     } else {
  615         // str subclasses may be unhashable or customize equality: retain tuple lookup.
  616         contains = PySequence_Contains(names.ptr(), key);

Lines 615-623

  615         // str subclasses may be unhashable or customize equality: retain tuple lookup.
  616         contains = PySequence_Contains(names.ptr(), key);
  617     }
  618     if (contains < 0)
! 619         return nullptr;
  620     if (!contains)
  621         return missing_key(key);
  622 
  623     // A custom key hook can replace the view's row during the membership check.

Lines 622-630

  622 
  623     // A custom key hook can replace the view's row during the membership check.
  624     py::object current_row = observed_row(self);
  625     if (!current_row)
! 626         return nullptr;
  627     plain_row = plain_row && current_row.ptr() == row.ptr();
  628     row = std::move(current_row);
  629     if (plain_row && PyUnicode_CheckExact(key) &&
  630         Py_TYPE(row.ptr())->tp_version_tag == self->row_version) {

Lines 629-641

  629     if (plain_row && PyUnicode_CheckExact(key) &&
  630         Py_TYPE(row.ptr())->tp_version_tag == self->row_version) {
  631         py::object columns = row_field(self, row.ptr(), COLUMNS, ATTR_COLUMNS);
  632         if (!columns)
! 633             return nullptr;
  634         if (PyDict_CheckExact(columns.ptr())) {
  635             int found = PyDict_Contains(columns.ptr(), key);
  636             if (found < 0)
! 637                 return nullptr;
  638             columns = py::object();
  639             if (found) {
  640                 py::object values = row_field(self, row.ptr(), VALUES, ATTR_VALUES);
  641                 if (!values)

Lines 642-653

  642                     return nullptr;
  643                 // Equality on a custom dict key can replace the row's column map.
  644                 columns = row_field(self, row.ptr(), COLUMNS, ATTR_COLUMNS);
  645                 if (!columns)
! 646                     return nullptr;
  647                 py::object index = steal(PyObject_GetItem(columns.ptr(), key));
  648                 if (!index)
! 649                     return nullptr;
  650                 return PyObject_GetItem(values.ptr(), index.ptr());
  651             }
  652 
  653             py::object lower = steal(PyObject_GetAttrString(row.ptr(), "_column_map_lower"));

Lines 651-679

  651             }
  652 
  653             py::object lower = steal(PyObject_GetAttrString(row.ptr(), "_column_map_lower"));
  654             if (!lower)
! 655                 return nullptr;
  656             if (!lower.is_none()) {
! 657                 lower = steal(PyObject_GetAttrString(row.ptr(), "_column_map_lower"));
! 658                 if (!lower)
! 659                     return nullptr;
! 660                 py::object get = steal(PyObject_GetAttrString(lower.ptr(), "get"));
! 661                 if (!get)
! 662                     return nullptr;
! 663                 py::object lowered = steal(PyObject_CallMethod(key, "lower", nullptr));
! 664                 if (!lowered)
! 665                     return nullptr;
! 666                 py::object index = steal(PyObject_CallOneArg(get.ptr(), lowered.ptr()));
! 667                 if (!index)
! 668                     return nullptr;
! 669                 if (!index.is_none()) {
! 670                     py::object values = row_field(self, row.ptr(), VALUES, ATTR_VALUES);
! 671                     if (!values)
! 672                         return nullptr;
! 673                     return PyObject_GetItem(values.ptr(), index.ptr());
! 674                 }
! 675             }
  676             py::object message = steal(PyUnicode_FromFormat("Row has no column '%U'", key));
  677             if (message)
  678                 PyErr_SetObject(PyExc_KeyError, message.ptr());
  679             return nullptr;

Lines 733-749

  733         if (reinterpret_cast<PyObject*>(Py_TYPE(names.ptr())) ==
  734             PyTuple_GET_ITEM(view->defaults, NAMES_TYPE)) {
  735             unique = get_unique(view, names.ptr());
  736             if (!unique) {
! 737                 iterator_clear(self);
! 738                 return nullptr;
! 739             }
  740         }
  741         if (unique && unique.ptr() != Py_False) {
  742             if (plain_row && prepare(view, row.ptr(), names.ptr(), unique.ptr()) < 0) {
! 743                 iterator_clear(self);
! 744                 return nullptr;
! 745             }
  746             self->iterator = PyObject_GetIter(unique.ptr());
  747         } else {
  748             self->iterator = PyObject_CallOneArg(
  749                 PyTuple_GET_ITEM(view->defaults, FALLBACK_ITERATOR), names.ptr());

Lines 748-763

  748             self->iterator = PyObject_CallOneArg(
  749                 PyTuple_GET_ITEM(view->defaults, FALLBACK_ITERATOR), names.ptr());
  750         }
  751         if (!self->iterator) {
! 752             iterator_clear(self);
! 753             return nullptr;
! 754         }
  755         if (!PyIter_Check(self->iterator)) {
! 756             PyErr_SetString(PyExc_TypeError, "Mapping key hook did not return an iterator");
! 757             iterator_clear(self);
! 758             return nullptr;
! 759         }
  760     }
  761     PyObject* value = PyIter_Next(self->iterator);
  762     if (!value)
  763         iterator_clear(self);

Lines 765-779

  765 }
  766 
  767 inline PyObject* iterate(View* self) {
  768     if (!self->defaults) {
! 769         PyErr_SetString(PyExc_AttributeError, "RowMapping has no row");
! 770         return nullptr;
! 771     }
  772     auto* type = reinterpret_cast<PyTypeObject*>(PyTuple_GET_ITEM(self->defaults, ITERATOR_TYPE));
  773     auto* iterator = reinterpret_cast<Iterator*>(type->tp_alloc(type, 0));
  774     if (!iterator)
! 775         return nullptr;
  776     Py_INCREF(self);
  777     iterator->view = self;
  778     return reinterpret_cast<PyObject*>(iterator);
  779 }

Lines 785-793

  785 
  786 inline py::object make_base(const py::object& row_type, const py::object& names_type,
  787                             const py::object& fallback_iterator) {
  788     if (!PyType_Check(row_type.ptr()) || !PyType_Check(names_type.ptr()))
! 789         throw py::type_error("Row and column names must be types");
  790 
  791     static PyType_Slot iterator_slots[] = {
  792         {Py_tp_dealloc, reinterpret_cast<void*>(iterator_dealloc)},
  793         {Py_tp_traverse, reinterpret_cast<void*>(iterator_traverse)},

Lines 801-813

  801         Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_GC, iterator_slots,
  802     };
  803     py::object iterator_type = steal(PyType_FromSpec(&iterator_spec));
  804     if (!iterator_type)
! 805         throw py::error_already_set();
  806     auto name = [](const char* text) {
  807         py::object result = steal(PyUnicode_InternFromString(text));
  808         if (!result)
! 809             throw py::error_already_set();
  810         return result;
  811     };
  812     auto* row_class = reinterpret_cast<PyTypeObject*>(row_type.ptr());
  813     py::object getattribute = name("__getattribute__");

Lines 871-879

  871         Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC, slots,
  872     };
  873     py::object type = steal(PyType_FromSpec(&spec));
  874     if (!type)
! 875         throw py::error_already_set();
  876     type.attr("_row_mapping_defaults") = configuration;
  877     type.attr("__slots__") = py::make_tuple("_row");
  878     type.attr("_prepared_supported") = !owner.is_none();
  879     return type;

mssql_python/pybind/row_mapping_cache.hpp

Lines 47-55

  47     std::lock_guard<std::mutex> lock(entries.mutex);
  48     for (Watcher* state = entries.head; state; state = state->next) {
  49         if (state->interpreter == interpreter) {
  50             if (state->epoch == std::numeric_limits<std::uint64_t>::max())
! 51                 state->enabled = false;
  52             else
  53                 ++state->epoch;
  54             break;
  55         }

Lines 60-70

  60 
  61 inline void destroy_watcher(PyObject* capsule) {
  62     auto* state = static_cast<Watcher*>(PyCapsule_GetPointer(capsule, "mssql.RowMappingWatcher"));
  63     if (!state) {
! 64         PyErr_WriteUnraisable(capsule);
! 65         return;
! 66     }
  67     {
  68         Registry& entries = registry();
  69         std::lock_guard<std::mutex> lock(entries.mutex);
  70         Watcher** link = &entries.head;

Lines 68-86

  68         Registry& entries = registry();
  69         std::lock_guard<std::mutex> lock(entries.mutex);
  70         Watcher** link = &entries.head;
  71         while (*link && *link != state)
! 72             link = &(*link)->next;
  73         if (*link)
  74             *link = state->next;
  75     }
  76     if (Py_IsInitialized()) {
! 77         PyObject *error_type, *error_value, *error_traceback;
! 78         PyErr_Fetch(&error_type, &error_value, &error_traceback);
! 79         if (PyDict_ClearWatcher(state->id) < 0)
! 80             PyErr_WriteUnraisable(capsule);
! 81         PyErr_Restore(error_type, error_value, error_traceback);
! 82     }
  83     delete state;
  84 }
  85 
  86 inline py::object watcher_owner() {

Lines 85-127

   85 
   86 inline py::object watcher_owner() {
   87     PyObject* dictionary = PyInterpreterState_GetDict(PyInterpreterState_Get());
   88     if (!dictionary) {
!  89         if (!PyErr_Occurred())
!  90             PyErr_SetString(PyExc_RuntimeError, "Cannot initialize RowMapping interpreter state");
!  91         throw py::error_already_set();
!  92     }
   93     py::str key("_mssql_python_row_mapping_watcher");
   94     PyObject* existing = PyDict_GetItemWithError(dictionary, key.ptr());
   95     if (existing) {
   96         if (existing != Py_None && !PyCapsule_IsValid(existing, "mssql.RowMappingWatcher"))
!  97             throw py::type_error("Invalid RowMapping watcher state");
   98         return borrow(existing);
   99     }
  100     if (PyErr_Occurred())
! 101         throw py::error_already_set();
  102     auto* state = new (std::nothrow) Watcher{PyInterpreterState_Get(), nullptr, 1, -1, true};
  103     if (!state)
! 104         throw std::bad_alloc();
  105     state->id = PyDict_AddWatcher(changed);
  106     if (state->id < 0) {
  107         delete state;
  108         if (!PyErr_ExceptionMatches(PyExc_RuntimeError))
! 109             throw py::error_already_set();
  110         PyErr_Clear();
  111         if (PyErr_WarnEx(PyExc_RuntimeWarning,
  112                         "RowMapping preparation disabled: no CPython dict watcher available", 1) < 0)
! 113             throw py::error_already_set();
  114         if (PyDict_SetItem(dictionary, key.ptr(), Py_None) < 0)
! 115             throw py::error_already_set();
  116         return py::none();
  117     }
  118     py::object owner = steal(PyCapsule_New(state, "mssql.RowMappingWatcher", destroy_watcher));
  119     if (!owner) {
! 120         PyDict_ClearWatcher(state->id);
! 121         delete state;
! 122         throw py::error_already_set();
! 123     }
  124     {
  125         Registry& entries = registry();
  126         std::lock_guard<std::mutex> lock(entries.mutex);
  127         state->next = entries.head;

Lines 127-135

  127         state->next = entries.head;
  128         entries.head = state;
  129     }
  130     if (PyDict_SetItem(dictionary, key.ptr(), owner.ptr()) < 0)
! 131         throw py::error_already_set();
  132     return owner;
  133 }
  134 #else
  135 inline py::object watcher_owner() { return py::none(); }

Lines 185-200

  185 inline int allocate_entries(Schema* self, Py_ssize_t size) {
  186     if (size == 0)
  187         return 0;
  188     if (size > PY_SSIZE_T_MAX / static_cast<Py_ssize_t>(sizeof(Entry))) {
! 189         PyErr_NoMemory();
! 190         return -1;
! 191     }
  192     self->entries = static_cast<Entry*>(PyMem_Calloc(static_cast<size_t>(size), sizeof(Entry)));
  193     if (!self->entries) {
! 194         PyErr_NoMemory();
! 195         return -1;
! 196     }
  197     return 0;
  198 }
  199 
  200 inline int append_entry(Schema* self, PyObject* key, PyObject* index) {

Lines 201-209

  201     // Exact ints only: overflow is a fallback marker, not an early lookup error.
  202     int overflow = 0;
  203     long long offset = PyLong_AsLongLongAndOverflow(index, &overflow);
  204     if (offset == -1 && PyErr_Occurred())
! 205         return -1;
  206     Entry& entry = self->entries[self->entry_count];
  207     entry.key = Py_NewRef(key);
  208     entry.index = Py_NewRef(index);
  209     entry.representable = overflow == 0 &&

Lines 235-243

  235         Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_GC, slots,
  236     };
  237     py::object type = steal(PyType_FromSpec(&spec));
  238     if (!type)
! 239         throw py::error_already_set();
  240     return type;
  241 }
  242 
  243 inline bool builtin_keys(PyObject* dictionary, bool integer_values = false) {


📋 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.pybind.connection.connection_pool.cpp: 81.3%
mssql_python.pybind.row_mapping_cache.hpp: 81.5%
mssql_python.pybind.connection.connection.cpp: 82.5%
mssql_python.logging.py: 86.2%
mssql_python.pybind.row_mapping.hpp: 86.8%
mssql_python.pooling.py: 90.1%

🔗 Quick Links

⚙️ Build Summary 📋 Coverage Details

View Azure DevOps Build

Browse Full Coverage Report

@jahnvi480 Jahnvi Thakkar (jahnvi480) changed the title REFACTOR: Speed up row-to-dictionary conversion and add CI coverage PERF: Speed up row-to-dictionary conversion and add CI coverage Oct 16, 2026
@jahnvi480
Jahnvi Thakkar (jahnvi480) marked this pull request as ready for review October 16, 2026 08:31

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.

No actionable findings.

Copilot AI balanced review requested due to automatic review settings November 4, 2026 06:17

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.

🔵 Needs a closer look

The native heap types, GC/refcount ownership, and dictionary-watcher invalidation require rebuilt-extension lifetime and shutdown validation across supported Python versions that this review environment cannot provide.

No open issues 🎉

Review effort: Balanced

@jahnvi480
Jahnvi Thakkar (jahnvi480) merged commit 54b0060 into main Nov 4, 2026
46 of 48 checks passed
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.

3 participants