Skip to content

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

Open
Jahnvi Thakkar (jahnvi480) wants to merge 2 commits into
mainfrom
jahnvi/rowmapping-speedup
Open

Jahnvi Thakkar (jahnvi480) wants to merge 2 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) 83.7% faster 84.5% 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.368 ms 31.537 ms -83.7%
Unix / SQL Server 2025 10,000 rows x 32 columns / dict(row._mapping) (warm) 200.271 ms 31.639 ms -84.5%
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.610 ms 10.302 ms -2.9% no signal
SELECT queries 1.076 ms 1.060 ms -1.3% no signal
Row insertion 34.588 ms 34.496 ms -0.6% no signal
Executemany inserts 161.071 ms 162.225 ms +0.9% no signal
Fetch-all queries 118.860 ms 119.653 ms +0.7% no signal
Row-by-row fetching 14.554 ms 14.742 ms +2.0% no signal
Batched row fetching 115.964 ms 118.730 ms +3.4% no signal
Transaction commit and rollback 112.789 ms 114.692 ms +0.5% no signal
Arrow row fetching 92.698 ms 94.449 ms +3.0% no signal
100,000-row insertion 453.082 ms 448.603 ms -1.6% no signal
Row fetching in batches of 100 121.357 ms 120.995 ms -0.5% no signal
Row fetching in batches of 10,000 117.842 ms 133.919 ms +13.8% no signal
Repeated positional queries 33.882 ms 33.922 ms -0.1% no signal
Repeated named-parameter queries 35.665 ms 35.987 ms +2.3% no signal
Legacy 100,000-row insertion 356.857 ms 351.575 ms -2.1% no signal
Insertion with explicit input sizes 483.923 ms 487.986 ms +0.4% no signal
Joined aggregation queries 174.823 ms 175.750 ms +0.4% no signal
Large joined-result fetching 178.327 ms 179.875 ms +0.9% no signal
1.2-million-row fetching 3413.512 ms 3462.394 ms +1.7% no signal
Common table expression queries 5.280 ms 5.366 ms +0.5% no signal
256 KiB VARCHAR(MAX) / fetchall() 1.314 ms 1.303 ms -1.4% no signal
10,000 scalar values / fetchval() (debug disabled) 108.435 ms 112.082 ms +3.7% no signal
10,000 rows x 32 columns / dict(row._mapping) (warm) 193.368 ms 31.537 ms -83.7% consistent improvement

Unix / SQL Server 2025

Database task Before After Paired change Result
Connection opening 98.183 ms 99.429 ms +0.4% no signal
SELECT queries 1.075 ms 1.098 ms +2.4% no signal
Row insertion 34.702 ms 34.829 ms -0.2% no signal
Executemany inserts 156.213 ms 158.545 ms +1.5% no signal
Fetch-all queries 121.799 ms 124.943 ms +2.4% no signal
Row-by-row fetching 14.708 ms 14.459 ms -2.4% no signal
Batched row fetching 119.117 ms 118.668 ms +1.3% no signal
Transaction commit and rollback 115.546 ms 115.777 ms -0.1% no signal
Arrow row fetching 92.406 ms 95.601 ms +1.9% no signal
100,000-row insertion 541.265 ms 492.676 ms -7.6% no signal
Row fetching in batches of 100 125.283 ms 126.577 ms +2.5% no signal
Row fetching in batches of 10,000 132.294 ms 143.893 ms +3.5% no signal
Repeated positional queries 34.015 ms 34.396 ms +1.9% no signal
Repeated named-parameter queries 37.149 ms 38.895 ms +4.3% no signal
Legacy 100,000-row insertion 366.917 ms 380.672 ms +3.7% no signal
Insertion with explicit input sizes 555.588 ms 501.434 ms -6.6% no signal
Joined aggregation queries 162.409 ms 161.929 ms -0.8% no signal
Large joined-result fetching 193.060 ms 199.132 ms +2.2% no signal
1.2-million-row fetching 3492.637 ms 3541.327 ms +1.3% no signal
Common table expression queries 5.116 ms 5.115 ms +0.7% no signal
256 KiB VARCHAR(MAX) / fetchall() 1.474 ms 1.567 ms +6.3% no signal
10,000 scalar values / fetchval() (debug disabled) 109.192 ms 108.697 ms +0.2% no signal
10,000 rows x 32 columns / dict(row._mapping) (warm) 200.271 ms 31.639 ms -84.5% consistent improvement
Build and measurement details

ADO build 183427

PR head: 7774d583bc7c7117c459c6134e6a685602cfedf6
Base: 9424705251baf8ded41a8a4968216b1fd9b00929
Measured merge: 7087c451c0cf0ed393cc1457f35923cf5ddf779a

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

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

Copy link
Copy Markdown

📊 Code Coverage Report

🔥 Diff Coverage

86%


🎯 Overall Coverage

85%


📈 Total Lines Covered: 10252 out of 12031
📁 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.row_mapping_cache.hpp: 81.5%
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.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
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.

2 participants