Skip to content

PERF: reuse native parameter bindings across repeated executes - #761

Draft
Gaurav Sharma (bewithgaurav) wants to merge 5 commits into
mainfrom
bewithgaurav/insertmany-perf-cached-bind
Draft

Gaurav Sharma (bewithgaurav) wants to merge 5 commits into
mainfrom
bewithgaurav/insertmany-perf-cached-bind

Conversation

@bewithgaurav

Copy link
Copy Markdown
Collaborator

Work Item / Issue Reference

GitHub Issue: #500


Summary

Repeated executions of the same prepared statement rebind every parameter from scratch on each call, even when the parameter shape never changes. This change gives each statement handle a single reusable generation of native input buffers and skips the SQLBindParameter loop when the next execution presents identical binding metadata.

The existing detector and binder still run in full on every execution: types are detected, values converted, and validation performed exactly as before. Only the redundant rebinding is elided. Reuse is gated on identical prepared SQL, parameter count, C and SQL types, column size, scale, direction, effective encoding, and actual buffer byte lengths and indicator addresses. String storage is updated in place only when the size is unchanged, which preserves each buffer address and its ODBC BufferLength.

Scope is deliberately conservative:

  • Cached: input-only integer, boolean, floating-point, and inline text/binary parameters, up to 2,100 parameters and 8,000 bytes per retained text/binary buffer.
  • Uncached (existing path): NULL, data-at-execution, and complex C types. Decimal overrides formatted to text reuse only with matching precision and scale.
  • Invalidated: new preparation, incompatible metadata or sizes, explicit reset, direct/catalog/array execution, statement-attribute changes, and any execution or conversion error.

Native storage stays owned until ODBC resets the bindings or frees the handle, including error paths and parent connection teardown, so no still-bound address is freed early and no Python reference is retained in the cache. The DB-API threadsafety=1 contract is unchanged. This is an internal reuse optimization with no public API or behavior change.


Validation

macOS arm64, Python 3.13, release build (-O3 -DNDEBUG, universal2), SQL Server 2022.

  • tests/test_037_cached_bindings.py: 73 reuse and invalidation cases pass. These assert live round trips and the actual native allocate/bind/reuse events through the existing debug logger, not a test-only API.
  • Core parameter and execute suites (test_004_cursor, test_010_pybind_functions, test_023_execute_path_parity): 699 pass, 8 skipped, no regressions.

Preliminary Local Measurements

Interleaved A/B against the cb0bd6c baseline (alternating before/after so machine noise lands on both), median of per-scenario medians across 3 rounds, with the worst round-to-round swing shown as spread.

Scenario Before After Delta Spread
Orders insert (int, varchar, decimal, datetime2) 475.0 ms 480.2 ms +1.1% 8.5%
Event log insert (uuid, datetime2, varchar, int) 453.1 ms 447.6 ms -1.2% 2.7%
Document insert (nvarchar(max) ~10KB, DAE) 1098.5 ms 1100.5 ms +0.2% 22.1%
Wide row insert (50 mixed columns) 734.5 ms 755.2 ms +2.8% 7.8%
Single-row execute x5000 2235.9 ms 2250.7 ms +0.7% 4.0%

Every delta is smaller than its own spread, so this wall-clock harness cannot resolve a difference on this machine. That is expected: these are end-to-end INSERT workloads where the SQL Server round trip dominates, and the client-side rebinding this change removes is a small fraction of each call. These numbers are published only to show the change does not regress, not to claim a speedup.

The isolated client-side effect will be quantified with the native profiler (tracked separately in #552) rather than end-to-end wall clock, and the measured numbers will be added here before this PR leaves draft.

Repeated executions of the same prepared statement rebound every parameter
from scratch on each call, even when the parameter shape never changed. Give
each statement handle a single reusable generation of native input buffers and
skip the SQLBindParameter loop when the next execution presents identical
binding metadata.

The existing detector and binder still run in full on every execution: types
are detected, values converted, and validation performed exactly as before.
Reuse is gated on identical prepared SQL, parameter count, C and SQL types,
column size, scale, direction, effective encoding, and actual buffer byte
lengths and indicator addresses; string storage is updated in place only when
the size is unchanged, preserving each buffer address and ODBC BufferLength.

Scope is deliberately conservative. Input-only integer, boolean, floating-point,
and inline text/binary parameters are cached, up to 2,100 parameters and 8,000
bytes per retained text/binary buffer. NULL, data-at-execution, and complex C
types fall back to the existing uncached path; decimal overrides formatted to
text reuse only with matching precision and scale. New preparation, incompatible
metadata or sizes, explicit reset, direct/catalog/array execution, statement
attribute changes, and any execution or conversion error invalidate reuse.

Native storage stays owned until ODBC resets the bindings or frees the handle,
including error paths and parent connection teardown, so no still-bound address
is freed early and no Python reference is retained in the cache. The DB-API
threadsafety=1 contract is unchanged. This is an internal reuse optimization
with no public API or behavior change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI lite review requested due to automatic review settings September 8, 2026 18:52
@github-actions github-actions Bot added the pr-size: large Substantial code update label Sep 8, 2026

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.

🟡 Changes recommended

The updated disconnect path can still drop the DBC handle in the no-GIL shutdown/destructor scenario without marking child statements as implicitly freed on SQLDisconnect failure, risking later double-free/ODBC calls on implicitly-freed HSTMTs.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR introduces a native, per-statement cache for execute-time parameter bindings in the C++ (ddbc_bindings) layer so repeated executions of the same prepared SQL can skip redundant SQLBindParameter calls when the binding “shape” is unchanged, while still re-running detection/validation/conversion each time.

Changes:

  • Add ExecuteBindingCache + preparedQuery tracking on SqlHandle, and implement reuse/invalidations across SQLExecute, reset, catalog, and direct execution paths.
  • Update Python cursor soft-reset to preserve compatible cached bindings (preserve_bindings=True).
  • Add an integration test suite that asserts reuse/invalidation behavior via existing debug-log events, plus documentation in the native README.
File summaries
File Description
tests/test_037_cached_bindings.py New integration tests validating bind reuse/invalidation via native debug logging.
mssql_python/pybind/README.md Document the repeated-execute binding reuse rules and invalidation triggers.
mssql_python/pybind/ddbc_bindings.h Add binding-cache structs and new SqlHandle fields/methods for caching + reset.
mssql_python/pybind/ddbc_bindings.cpp Implement buffer reuse/caching, invalidation, and reset behavior across execution paths.
mssql_python/pybind/connection/connection.cpp Adjust disconnect sequencing/lifetime handling for child statement owners and caches.
mssql_python/cursor.py Preserve compatible cached bindings during soft cursor reset.
Review details
  • Files reviewed: 6/6 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread mssql_python/pybind/connection/connection.cpp Outdated
…ails

Connection::disconnect() gated marking the tracked child statement handles as
implicitly freed on SQLDisconnect returning success, but the parent DBC handle
is reset unconditionally right afterwards. Freeing the DBC frees every child
statement handle, so when SQLDisconnect returned a non-success code (for
example on Windows when a cursor outlives its connection and statements are
still open) the children were never marked, and later cursor garbage collection
called SQLFreeHandle on an already-freed handle, faulting with an access
violation.

Mark every retained child implicitly freed and release its native binding
storage unconditionally after SQLDisconnect returns, before the DBC handle is
reset. Owning references are still held across the blocking disconnect so the
cached binding buffers stay valid until the call completes, preserving the
reuse feature's lifetime guarantee while restoring the double-free protection
that predated it.

Also give the large parameter cases in test_037_cached_bindings.py explicit
short parametrize ids so the multi-thousand-character values no longer expand
into node ids that exceed the Windows 32767-character environment limit during
collection.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 9, 2026 04:24

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.

🟡 Changes recommended

Connection::disconnect() can drop tracking for statement handles allocated concurrently during the unlocked SQLDisconnect call, risking later double-free behavior unless the teardown captures/marks all handles before clearing the tracking list.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 6/6 changed files
  • Comments generated: 2
  • Review effort level: Lite

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

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

📊 Code Coverage Report

🔥 Diff Coverage

87%


🎯 Overall Coverage

83%


📈 Total Lines Covered: 8474 out of 10155
📁 Project: mssql-python


Diff Coverage

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

  • mssql_python/cursor.py (100%)
  • mssql_python/pybind/connection/connection.cpp (100%)
  • mssql_python/pybind/ddbc_bindings.cpp (82.0%): Missing lines 451,915-917,1613,1644-1645,1651-1652,1654-1655,1710,1812-1813,1849-1850,1877,1942-1944,1971-1972,2184,2247-2250,2368-2369
  • mssql_python/pybind/param_bind_cache.hpp (97.1%): Missing lines 123,132

Summary

  • Total: 244 lines
  • Missing: 31 lines
  • Coverage: 87%

mssql_python/pybind/ddbc_bindings.cpp

Lines 447-455

  447 // each of them with appropriate arguments
  448 SQLRETURN BindParameters(SqlHandle& handle, SQLHANDLE hStmt, const py::list& params,
  449                          std::vector<ParamInfo>& paramInfos,
  450                          std::vector<std::shared_ptr<void>>& ownedBuffers,
! 451                          const std::string& charEncoding = "utf-8", bool cacheForExecute = false) {
  452     PERF_TIMER("BindParameters");
  453     LOG("BindParameters: Starting parameter binding for statement handle %p "
  454         "with %zu parameters",
  455         (void*)hStmt, params.size());

Lines 911-921

  911         handle.executeBindings = std::move(cache);
  912     }
  913     for (int paramIndex = 0; paramIndex < bindings.size(); ++paramIndex) {
  914         const ParamInfo& paramInfo = paramInfos[paramIndex];
! 915         const auto& binding = bindings[paramIndex];
! 916         void* dataPtr = binding.data;
! 917         SQLLEN bufferLength = binding.length;
  918         SQLLEN* strLenOrIndPtr = binding.indicator;
  919         assert(SQLBindParameter_ptr && SQLGetStmtAttr_ptr && SQLSetDescField_ptr);
  920         LOG("BindParameters: SQLBindParameter param[%d]", paramIndex);
  921         RETCODE rc;

Lines 1609-1617

  1609     // If the driver refused to free the handle, it can still reference these
  1610     // addresses. Leak only this failed teardown's native storage, not dangling
  1611     // pointers into freed memory. Explicit free() failures retain ownership.
  1612     if (_handle && !_implicitly_freed)
! 1613         executeBindings.release();
  1614 }
  1615 
  1616 SQLHANDLE SqlHandle::get() const {
  1617     return _handle;

Lines 1640-1649

  1640 SQLRETURN SqlHandle::resetParameterBindings() {
  1641     if (!executeBindings)
  1642         return SQL_SUCCESS;
  1643     executeBindings->reusable = false;
! 1644     if (!_handle || _implicitly_freed)
! 1645         return SQL_INVALID_HANDLE;
  1646     SQLRETURN rc = SQLFreeStmt_ptr(_handle, SQL_RESET_PARAMS);
  1647     if (SQL_SUCCEEDED(rc))
  1648         executeBindings.reset();
  1649     return rc;

Lines 1647-1659

  1647     if (SQL_SUCCEEDED(rc))
  1648         executeBindings.reset();
  1649     return rc;
  1650 }
! 1651 
! 1652 void SqlHandle::releaseAfterFree() {
  1653     _handle = nullptr;
! 1654     executeBindings.reset();
! 1655     preparedQuery.clear();
  1656     describeCache.clear();
  1657 }
  1658 
  1659 /*

Lines 1706-1714

  1706         if (!pythonShuttingDown && PyGILState_Check()) {
  1707             py::gil_scoped_release release;
  1708             rc = SQLFreeHandle_ptr(_type, _handle);
  1709         } else {
! 1710             rc = SQLFreeHandle_ptr(_type, _handle);
  1711         }
  1712         if (SQL_SUCCEEDED(rc)) {
  1713             releaseAfterFree();
  1714         } else if (executeBindings) {

Lines 1808-1817

  1808     {
  1809         py::gil_scoped_release release;
  1810         rc = SQLFreeStmt_ptr(hStmt, SQL_CLOSE);
  1811         if (SQL_SUCCEEDED(rc) && !(preserveBindings && statementHandle->executeBindings &&
! 1812                                    statementHandle->executeBindings->reusable)) {
! 1813             if (statementHandle->executeBindings) {
  1814                 rc = statementHandle->resetParameterBindings();
  1815             } else {
  1816                 rc = SQLFreeStmt_ptr(hStmt, SQL_RESET_PARAMS);
  1817             }

Lines 1845-1854

  1845 SQLRETURN SQLProcedures_wrap(SqlHandlePtr StatementHandle, const py::object& catalogObj,
  1846                              const py::object& schemaObj, const py::object& procedureObj) {
  1847     PERF_TIMER("SQLProcedures_wrap");
  1848     SQLRETURN reset = StatementHandle->resetParameterBindings();
! 1849     if (!SQL_SUCCEEDED(reset))
! 1850         return reset;
  1851     StatementHandle->preparedQuery.clear();
  1852     StatementHandle->clearDescribeCache();
  1853     if (!SQLProcedures_ptr) {
  1854         ThrowStdException("SQLProcedures function not loaded");

Lines 1873-1881

  1873                               const py::object& pkSchemaObj, const py::object& pkTableObj,
  1874                               const py::object& fkCatalogObj, const py::object& fkSchemaObj,
  1875                               const py::object& fkTableObj) {
  1876     PERF_TIMER("SQLForeignKeys_wrap");
! 1877     SQLRETURN reset = StatementHandle->resetParameterBindings();
  1878     if (!SQL_SUCCEEDED(reset))
  1879         return reset;
  1880     StatementHandle->preparedQuery.clear();
  1881     StatementHandle->clearDescribeCache();

Lines 1938-1948

  1938                              const py::object& schemaObj, const std::u16string& table,
  1939                              SQLUSMALLINT unique, SQLUSMALLINT reserved) {
  1940     PERF_TIMER("SQLStatistics_wrap");
  1941     SQLRETURN reset = StatementHandle->resetParameterBindings();
! 1942     if (!SQL_SUCCEEDED(reset))
! 1943         return reset;
! 1944     StatementHandle->preparedQuery.clear();
  1945     StatementHandle->clearDescribeCache();
  1946     if (!SQLStatistics_ptr) {
  1947         ThrowStdException("SQLStatistics function not loaded");
  1948     }

Lines 1967-1976

  1967     PERF_TIMER("SQLColumns_wrap");
  1968     SQLRETURN reset = StatementHandle->resetParameterBindings();
  1969     if (!SQL_SUCCEEDED(reset))
  1970         return reset;
! 1971     StatementHandle->preparedQuery.clear();
! 1972     StatementHandle->clearDescribeCache();
  1973     if (!SQLColumns_ptr) {
  1974         ThrowStdException("SQLColumns function not loaded");
  1975     }

Lines 2180-2188

  2180     if (!statementHandle || !statementHandle->get() || statementHandle->isImplicitlyFreed()) {
  2181         return SQL_INVALID_HANDLE;
  2182     }
  2183 
! 2184     struct ExecutionAttempt {
  2185         SqlHandle& handle;
  2186         bool succeeded = false;
  2187         ~ExecutionAttempt() {
  2188             if (!succeeded && handle.executeBindings)

Lines 2243-2254

  2243     // - use_prepare=false + already prepared: reuse existing plan
  2244     // - use_prepare=false + not prepared: error (cannot execute unprepared)
  2245     if (!already_prepared) {
  2246         if (use_prepare) {
! 2247             rc = statementHandle->resetParameterBindings();
! 2248             if (!SQL_SUCCEEDED(rc))
! 2249                 return rc;
! 2250             statementHandle->preparedQuery.clear();
  2251             SQLWCHAR* queryPtr = reinterpretU16stringAsSqlWChar(query);
  2252             {
  2253                 py::gil_scoped_release release;
  2254                 rc = SQLPrepare_ptr(hStmt, queryPtr, SQL_NTS);

Lines 2364-2373

  2364     if (!statementHandle->executeBindings->reusable) {
  2365         rc = statementHandle->resetParameterBindings();
  2366         if (!SQL_SUCCEEDED(rc))
  2367             return rc;
! 2368     }
! 2369     attempt.succeeded = true;
  2370     return exec_rc;
  2371 }
  2372 
  2373 SQLRETURN BindParameterArray(SqlHandle& handle, SQLHANDLE hStmt, const py::list& columnwise_params,

mssql_python/pybind/param_bind_cache.hpp

Lines 119-127

  119 static bool CanCacheParameters(const std::vector<ParamInfo>& infos) {
  120     // Bound retained storage to SQL Server's scalar parameter limit. NULL/DAE and
  121     // descriptor-based/complex types deliberately use the existing uncached path.
  122     if (infos.empty() || infos.size() > 2100)
! 123         return false;
  124     for (const auto& info : infos) {
  125         if (info.isDAE || info.inputOutputType != SQL_PARAM_INPUT)
  126             return false;
  127         switch (info.paramCType) {

Lines 128-136

  128             case SQL_C_CHAR:
  129             case SQL_C_WCHAR:
  130             case SQL_C_BINARY:
  131                 if (info.columnSize > MAX_INLINE_BINARY)
! 132                     return false;
  133                 break;
  134             case SQL_C_BIT:
  135             case SQL_C_STINYINT:
  136             case SQL_C_TINYINT:


📋 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: 61.5%
mssql_python.row.py: 77.6%
mssql_python.pybind.ddbc_bindings.cpp: 78.9%
mssql_python.pybind.connection.connection_pool.cpp: 81.8%
mssql_python.pybind.connection.connection.cpp: 85.2%
mssql_python.logging.py: 85.5%
mssql_python.helpers.py: 89.3%
mssql_python.pooling.py: 90.1%

🔗 Quick Links

⚙️ Build Summary 📋 Coverage Details

View Azure DevOps Build

Browse Full Coverage Report

The handle-owned parameter-binding reuse cache was added inline to the ~6,000
line ddbc_bindings.cpp monolith and its struct definitions to ddbc_bindings.h.
It is a self-contained unit, so move it into its own single-responsibility
header, matching how the perf work already carved parameter detection into
param_detect.hpp, the type cache into py_type_cache.hpp, and the refcount
helpers into py_ref.hpp.

param_bind_cache.hpp now owns ParameterBinding, ExecuteBindingCache,
ExecuteParamBuffers, the single-buffer AllocateParamBuffer template and its
in-place-reuse overload, UpdateParamBuffer, SameParameterShape, and
CanCacheParameters. ddbc_bindings.h keeps only a forward declaration of
ExecuteBindingCache, which is all SqlHandle needs since it holds a unique_ptr to
it and defines every method that touches it out of line; this also avoids
pulling param_detect.hpp (which includes ddbc_bindings.h back) into the handle
header. AllocateParamBufferArray stays in ddbc_bindings.cpp because it serves the
executemany array-binding path, not the reuse cache.

Pure relocation with no behavior change: the moved code is byte-identical, the
release universal2 build is clean, and the full suite is unchanged (2344 passed,
the lone test_004 stored-procedure failure is a pre-existing stale-DB-object
artifact that passes in isolation).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 9, 2026 06:47

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.

🟡 Changes recommended

Connection teardown currently drops cached parameter buffers before the parent DBC handle is freed, which can invalidate still-referenced bound addresses during ODBC cleanup.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

mssql_python/pybind/ddbc_bindings.h:313

  • The markImplicitlyFreed() doc comment says it must only be called after SQLDisconnect has succeeded, but Connection::disconnect() calls it regardless of SQLDisconnect result (and then frees the DBC handle anyway). This makes the contract misleading for future callers.
    // SAFETY CONSTRAINTS:
    // - ONLY call this on SQL_HANDLE_STMT handles
    // - ONLY call this after the parent SQLDisconnect has succeeded
    // - Calling on other handle types (ENV, DBC, DESC) will cause HANDLE LEAKS
  • Files reviewed: 7/7 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread mssql_python/pybind/connection/connection.cpp Outdated
Comment thread mssql_python/pybind/param_bind_cache.hpp
Merge main at 9122376, including profiler PR #552 and the other upstream updates. Preserve the cached-binding implementation and header extraction while resolving overlapping instrumentation in ddbc_bindings.cpp. Keep the upstream timers around the actual ODBC bind calls and retain the cache invalidation and handle guards.

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

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.

🟡 Changes recommended

The new cache header currently relies on include order for the LOG macro and there are updated comments that conflict with the actual disconnect/implicit-free control flow.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (2)

mssql_python/pybind/param_bind_cache.hpp:37

  • param_bind_cache.hpp uses the LOG macro inside the header, but the header does not include logger_bridge.hpp (where LOG is defined). This makes the header dependent on include order and can break compilation if it’s included from another translation unit later.
#include "param_detect.hpp"  // ParamInfo, MAX_INLINE_BINARY, ODBC types and constants

mssql_python/pybind/ddbc_bindings.h:312

  • The safety constraint for markImplicitlyFreed() is too strict given current usage: Connection::disconnect() can call it in GIL-less/shutdown paths even when SQLDisconnect returns an error (errors are suppressed), as long as the DBC handle is still being freed. The comment should match the actual requirement (parent DBC handle will be freed).
    // - ONLY call this after the parent SQLDisconnect has succeeded
  • Files reviewed: 7/7 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread mssql_python/pybind/connection/connection.cpp Outdated
Distinguish successful SQLDisconnect from GIL-held errors and terminal GIL-less wrapper retirement. Remove comments implying that dropping a DBC owner always proves native deallocation. Include logger_bridge.hpp directly in the cache header instead of relying on a transitive include. Runtime cleanup and binding behavior are unchanged.

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

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.

🟡 Changes recommended

The current SQLExecute_wrap can turn a successful execute into a failure due to post-execute cleanup error propagation, which is an observable behavior change that should be addressed before approval.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 7/7 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment on lines 2363 to +2367
SQLRETURN exec_rc = rc;
SQLFreeStmt_ptr(hStmt, SQL_RESET_PARAMS);
if (!statementHandle->executeBindings->reusable) {
rc = statementHandle->resetParameterBindings();
if (!SQL_SUCCEEDED(rc))
return rc;
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