FIX: Cache SQLDescribeParam results for NULL parameters to reduce server round-trips#614
Open
jahnvi480 wants to merge 15 commits into
Open
FIX: Cache SQLDescribeParam results for NULL parameters to reduce server round-trips#614jahnvi480 wants to merge 15 commits into
jahnvi480 wants to merge 15 commits into
Conversation
…lared_parameters round-trips (GH-610) When a parameterized query includes None values, the driver previously sent SQL_UNKNOWN_TYPE to the C++ layer, which triggered a SQLDescribeParam call (internally sp_describe_undeclared_parameters) for every NULL param on every execute(). This caused thousands of unnecessary server round-trips per minute for workloads with frequent NULLs (e.g. sp_set_session_context). The describe call fails most of the time — especially for stored procedure calls — and the C++ layer already falls back to SQL_VARCHAR, so the round-trip adds latency with no benefit. Fix: Return SQL_VARCHAR directly from _map_sql_type() for None params, matching the existing executemany() all-NULL column optimisation. Closes #610
Contributor
There was a problem hiding this comment.
Pull request overview
Optimizes NULL (None) parameter type mapping in mssql_python to avoid triggering SQLDescribeParam (and the associated sp_describe_undeclared_parameters server round-trips) on every execution involving NULL parameters, improving performance for heavily parameterized workloads.
Changes:
- Update
Cursor._map_sql_typeto mapNoneparameters toSQL_VARCHARinstead ofSQL_UNKNOWN_TYPE. - Add a unit test asserting
_map_sql_typereturns the expected tuple forNoneparameters (GH-610).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
mssql_python/cursor.py |
Changes NULL parameter SQL type mapping to SQL_VARCHAR to avoid SQLDescribeParam round-trips. |
tests/test_001_globals.py |
Adds a regression test verifying _map_sql_type(None, ...) returns SQL_VARCHAR and related defaults. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
📊 Code Coverage Report
Diff CoverageDiff: main...HEAD, staged and unstaged changes
Summary
mssql_python/pybind/ddbc_bindings.cppLines 491-500 491 "SQLDescribeParam", (void*)hStmt, paramIndex);
492 RETCODE rc = SQLDescribeParam_ptr(
493 hStmt, static_cast<SQLUSMALLINT>(paramIndex + 1),
494 &type, &size, &digits, &nullable);
! 495
! 496 DescribedParamInfo info;
497 if (SQL_SUCCEEDED(rc)) {
498 info = {type, size, digits, true};
499 LOG("ResolveNullParamType: SQLDescribeParam succeeded for param[%d] "
500 "-> sqlType=%d, columnSize=%lu, decimalDigits=%d",Lines 673-682 673 SQLSMALLINT sqlType = paramInfo.paramSQLType;
674 SQLULEN columnSize = paramInfo.columnSize;
675 SQLSMALLINT decimalDigits = paramInfo.decimalDigits;
676 if (sqlType == SQL_UNKNOWN_TYPE) {
! 677 auto resolved = ResolveNullParamType(handle, hStmt, paramIndex);
! 678 sqlType = resolved.sqlType;
679 columnSize = resolved.columnSize;
680 decimalDigits = resolved.decimalDigits;
681 }
682 dataPtr = nullptr;Lines 1481-1489 1481
1482 // Release the GIL during the blocking ODBC catalog call
1483 py::gil_scoped_release release;
1484 return SQLProcedures_ptr(
! 1485 StatementHandle->get(), catalog.empty() ? nullptr : reinterpretU16stringAsSqlWChar(catalog),
1486 catalog.empty() ? 0 : SQL_NTS,
1487 schema.empty() ? nullptr : reinterpretU16stringAsSqlWChar(schema),
1488 schema.empty() ? 0 : SQL_NTS,
1489 procedure.empty() ? nullptr : reinterpretU16stringAsSqlWChar(procedure),Lines 1535-1545 1535 // Release the GIL during the blocking ODBC catalog call
1536 py::gil_scoped_release release;
1537 return SQLPrimaryKeys_ptr(StatementHandle->get(),
1538 catalog.empty() ? nullptr : reinterpretU16stringAsSqlWChar(catalog),
! 1539 catalog.empty() ? 0 : SQL_NTS,
! 1540 schema.empty() ? nullptr : reinterpretU16stringAsSqlWChar(schema),
! 1541 schema.empty() ? 0 : SQL_NTS,
1542 table.empty() ? nullptr : reinterpretU16stringAsSqlWChar(table),
1543 table.empty() ? 0 : SQL_NTS);
1544 }Lines 1612-1620 1612 SQLSMALLINT messageLen;
1613
1614 SQLRETURN diagReturn =
1615 SQLGetDiagRec_ptr(handleType, rawHandle, 1, sqlState, &nativeError, message,
! 1616 SQL_MAX_MESSAGE_LENGTH_SQLSERVER, &messageLen);
1617
1618 if (SQL_SUCCEEDED(diagReturn)) {
1619 std::u16string sqlStateUtf16 = dupeSqlWCharAsUtf16Le(sqlState, 5);
1620 std::u16string messageUtf16 = dupeSqlWCharAsUtf16Le(Lines 1724-1734 1724 catalog.empty() ? nullptr : reinterpretU16stringAsSqlWChar(catalog),
1725 catalog.empty() ? 0 : SQL_NTS,
1726 schema.empty() ? nullptr : reinterpretU16stringAsSqlWChar(schema),
1727 schema.empty() ? 0 : SQL_NTS,
! 1728 table.empty() ? nullptr : reinterpretU16stringAsSqlWChar(table),
! 1729 table.empty() ? 0 : SQL_NTS,
! 1730 tableType.empty() ? nullptr : reinterpretU16stringAsSqlWChar(tableType),
1731 tableType.empty() ? 0 : SQL_NTS);
1732 }
1733
1734 LOG("SQLTables: Catalog metadata query %s - SQLRETURN=%d",Lines 1808-1816 1808 rc, (void*)hStmt);
1809 return rc;
1810 }
1811 // GH-610: Clear per-handle describe cache (new prepare = new param types)
! 1812 statementHandle->clearDescribeCache();
1813 isStmtPrepared[0] = py::cast(true);
1814 } else {
1815 // Make sure the statement has been prepared earlier if we're not
1816 // preparing nowLines 1829-1837 1829 charEncoding = encodingSettings["encoding"].cast<std::string>();
1830 }
1831
1832 std::vector<std::shared_ptr<void>> paramBuffers;
! 1833 rc = BindParameters(*statementHandle, hStmt, params, paramInfos, paramBuffers, charEncoding);
1834 if (!SQL_SUCCEEDED(rc)) {
1835 return rc;
1836 }Lines 2569-2587 2569 LOG("BindParameterArray: Binding SQL_C_DEFAULT (NULL) array - param_index=%d, "
2570 "count=%zu",
2571 paramIndex, paramSetSize);
2572
! 2573 // GH-610: Resolve SQL type for all-NULL columns via per-handle cache.
! 2574 SQLSMALLINT resolvedSqlType = info.paramSQLType;
! 2575 SQLULEN resolvedColSize = info.columnSize;
! 2576 SQLSMALLINT resolvedDecDigits = info.decimalDigits;
2577 if (resolvedSqlType == SQL_UNKNOWN_TYPE) {
! 2578 auto resolved = ResolveNullParamType(handle, hStmt, paramIndex);
! 2579 resolvedSqlType = resolved.sqlType;
! 2580 resolvedColSize = resolved.columnSize;
! 2581 resolvedDecDigits = resolved.decimalDigits;
! 2582 }
! 2583
2584 char* nullBuffer = AllocateParamBufferArray<char>(tempBuffers, paramSetSize);
2585 strLenOrIndArray = AllocateParamBufferArray<SQLLEN>(tempBuffers, paramSetSize);
2586
2587 for (size_t i = 0; i < paramSetSize; ++i) {Lines 2590-2598 2590 }
2591
2592 dataPtr = nullBuffer;
2593 bufferLength = 1;
! 2594
2595 // Override info fields so SQLBindParameter below uses resolved type
2596 const_cast<ParamInfo&>(info).paramSQLType = resolvedSqlType;
2597 const_cast<ParamInfo&>(info).columnSize = resolvedColSize;
2598 const_cast<ParamInfo&>(info).decimalDigits = resolvedDecDigits;Lines 2877-2888 2877 py::gil_scoped_release release;
2878 return SQLSpecialColumns_ptr(StatementHandle->get(), identifierType,
2879 catalog.empty() ? nullptr
2880 : reinterpretU16stringAsSqlWChar(catalog),
! 2881 catalog.empty() ? 0 : SQL_NTS,
! 2882 schema.empty() ? nullptr : reinterpretU16stringAsSqlWChar(schema),
! 2883 schema.empty() ? 0 : SQL_NTS,
! 2884 table.empty() ? nullptr : reinterpretU16stringAsSqlWChar(table),
2885 table.empty() ? 0 : SQL_NTS, scope, nullable);
2886 }
2887
2888 // Wrap SQLFetch to retrieve rowsLines 3374-3382 3374 // exact number of characters via dataLen, so do not rely on
3375 // null termination. This preserves embedded NULs and avoids
3376 // any risk of reading past the valid range if the driver
3377 // omits the terminator.
! 3378 row.append(py::cast(
3379 dupeSqlWCharAsUtf16Le(dataBuffer.data(), numCharsInData)));
3380 LOG("SQLGetData: Appended NVARCHAR string "
3381 "length=%lu for column %d",
3382 (unsigned long)numCharsInData, i);📋 Files Needing Attention📉 Files with overall lowest coverage (click to expand)mssql_python.pybind.logger_bridge.cpp: 59.2%
mssql_python.pybind.ddbc_bindings.h: 59.9%
mssql_python.pybind.logger_bridge.hpp: 70.8%
mssql_python.pybind.ddbc_bindings.cpp: 76.3%
mssql_python.row.py: 76.9%
mssql_python.__init__.py: 77.3%
mssql_python.pybind.connection.connection.cpp: 77.3%
mssql_python.ddbc_bindings.py: 79.6%
mssql_python.logging.py: 85.5%
mssql_python.connection.py: 85.6%🔗 Quick Links
|
… jahnvi/fix-null-param-describe-610
34b05de to
b6f1478
Compare
subrata-ms
previously approved these changes
Jun 2, 2026
…o longer reachable since _map_sql_type(None) now returns SQL_VARCHAR directly.
sumitmsft
reviewed
Jun 3, 2026
… jahnvi/fix-null-param-describe-610
… jahnvi/fix-null-param-describe-610
…declared_parameters round-trips (GH-610) When cursor.execute() includes None (NULL) params, the C++ BindParameters layer calls SQLDescribeParam for each NULL param on every execution. This triggers sp_describe_undeclared_parameters on SQL Server — a full network round-trip per NULL param per call. Fix: Add a statement-level cache in C++ that stores SQLDescribeParam results per (hStmt, paramIndex). First execution describes and caches; all subsequent executions of the same prepared statement get cache hits with zero round-trips. Cache is cleared on SQLPrepare (new SQL = new param types). Changes: - ddbc_bindings.cpp: Add DescribedParamInfo cache struct, ResolveNullParamType helper, cache invalidation on SQLPrepare in both SQLExecute_wrap and SQLExecuteMany_wrap. Both BindParameters and BindParameterArray use the cache for SQL_UNKNOWN_TYPE NULL params. - ddbc_bindings.h: Restore SQLDescribeParamFunc typedef and extern. - cursor.py: Revert _map_sql_type to return SQL_UNKNOWN_TYPE for None (let C++ cache handle resolution). Remove executemany SQL_VARCHAR hardcoded fallback (C++ BindParameterArray now resolves via cache). - test_004_cursor.py: Update test to verify SQL_UNKNOWN_TYPE for None. Closes #610
Add 7 integration tests covering all cache code paths: - Cache miss (first execute with NULL param) - Cache hit (repeated execute with same SQL + NULL) - Cache invalidation (different SQL triggers re-prepare) - executemany all-NULL column (BindParameterArray path) - executemany multiple all-NULL columns - All-NULL params in execute - setinputsizes bypass (explicit type skips cache)
sumitmsft
reviewed
Jun 5, 2026
sumitmsft
reviewed
Jun 5, 2026
… jahnvi/fix-null-param-describe-610
…om/microsoft/mssql-python into jahnvi/fix-null-param-describe-610
…H-610) Address reviewer feedback (sumitmsft): 1. Memory leak fix: Cache entries are now freed when SqlHandle::free() is called (all exit paths), preventing orphaned entries from freed HSTMT handles, pooled connections, and implicit frees. 2. Contention fix: Cache moved from global std::shared_mutex + std::unordered_map to a per-SqlHandle member. No mutex needed since each handle is used by one thread. Eliminates the global serialization point for multi-threaded workloads. Changes: - ddbc_bindings.h: Add DescribedParamInfo struct and describeCache member + clearDescribeCache() to SqlHandle class. - ddbc_bindings.cpp: Add ResolveNullParamType() helper that uses per-handle cache. Update BindParameters and BindParameterArray signatures to take SqlHandle&. Clear cache on SQLPrepare in both SQLExecute_wrap and SQLExecuteMany_wrap. Clear cache in SqlHandle::free(). - Remove global g_describeCache and g_describeCacheMutex. 522 tests pass, 0 regressions.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Work Item / Issue Reference
Summary
This pull request implements a robust caching mechanism for SQL parameter type resolution when binding
None(NULL) parameters in the MSSQL Python driver, addressing issues with repeated round-trips toSQLDescribeParamand previous incorrect type fallbacks. The changes affect both the Python and C++ layers, improving performance and correctness, especially for all-NULL columns and VARBINARY types. Comprehensive tests are added to ensure correctness and coverage of the new cache logic.Enhancements to NULL parameter type resolution and caching:
ddbc_bindings.cppto storeSQLDescribeParamresults per statement handle and parameter index, reducing redundant round-trips and ensuring correct type inference for NULL parameters. This replaces the previous fallback toSQL_VARCHAR, which caused issues with certain column types.BindParametersandBindParameterArray) to use the new cache for resolving types of NULL parameters, ensuring consistency and correctness for all-NULL columns.Cache invalidation and integration:
SQLExecute_wrapandSQLExecuteMany_wrap.Python interface and type mapping:
_map_sql_typeincursor.pyto always returnSQL_UNKNOWN_TYPEforNoneparameters, delegating type resolution to the C++ cache. Removed the previous Python-side fallback toSQL_VARCHARfor all-NULL columns inexecutemany.Testing improvements:
test_004_cursor.pyto cover cache hit/miss scenarios, cache invalidation, all-NULL columns, and bypassing the cache with explicit type hints, ensuring the new logic is robust and correct.Codebase maintenance:
Included necessary C++ headers for thread-safety and updated function pointer loading logs for clarity.
Minor loop index and logging improvements for clarity and correctness in parameter binding.
These changes collectively resolve previous issues with NULL parameter handling, improve driver efficiency, and provide comprehensive test coverage for the new behavior.