Skip to content

Commit c4d434f

Browse files
authored
Merge branch 'main' into jahnvi/ghissue_203
2 parents 3f721c4 + 2398ffd commit c4d434f

File tree

9 files changed

+2267
-6
lines changed

9 files changed

+2267
-6
lines changed

OneBranchPipelines/stress-test-pipeline.yml

Lines changed: 414 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""
2+
Benchmark: Credential Instance Caching for Azure AD Authentication
3+
4+
Measures the performance difference between:
5+
1. Creating a new DefaultAzureCredential + get_token() each call (old behavior)
6+
2. Reusing a cached DefaultAzureCredential instance (new behavior)
7+
8+
Prerequisites:
9+
- pip install azure-identity azure-core
10+
- az login (for AzureCliCredential to work)
11+
12+
Usage:
13+
python benchmarks/bench_credential_cache.py
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import time
19+
import statistics
20+
21+
22+
def bench_no_cache(n: int) -> list[float]:
23+
"""Simulate the OLD behavior: new credential per call."""
24+
from azure.identity import DefaultAzureCredential
25+
26+
times = []
27+
for _ in range(n):
28+
start = time.perf_counter()
29+
cred = DefaultAzureCredential()
30+
cred.get_token("https://database.windows.net/.default")
31+
times.append(time.perf_counter() - start)
32+
return times
33+
34+
35+
def bench_with_cache(n: int) -> list[float]:
36+
"""Simulate the NEW behavior: reuse a single credential instance."""
37+
from azure.identity import DefaultAzureCredential
38+
39+
cred = DefaultAzureCredential()
40+
times = []
41+
for _ in range(n):
42+
start = time.perf_counter()
43+
cred.get_token("https://database.windows.net/.default")
44+
times.append(time.perf_counter() - start)
45+
return times
46+
47+
48+
def report(label: str, times: list[float]) -> None:
49+
print(f"\n{'=' * 50}")
50+
print(f" {label}")
51+
print(f"{'=' * 50}")
52+
print(f" Calls: {len(times)}")
53+
print(f" Total: {sum(times):.3f}s")
54+
print(f" Mean: {statistics.mean(times) * 1000:.1f}ms")
55+
print(f" Median: {statistics.median(times) * 1000:.1f}ms")
56+
print(f" Stdev: {statistics.stdev(times) * 1000:.1f}ms" if len(times) > 1 else "")
57+
print(f" Min: {min(times) * 1000:.1f}ms")
58+
print(f" Max: {max(times) * 1000:.1f}ms")
59+
60+
61+
def main() -> None:
62+
N = 10 # number of calls to benchmark
63+
64+
print("Credential Instance Cache Benchmark")
65+
print(f"Running {N} sequential token acquisitions for each scenario...\n")
66+
67+
try:
68+
print(">>> Without cache (new credential each call)...")
69+
no_cache_times = bench_no_cache(N)
70+
report("WITHOUT credential cache (old behavior)", no_cache_times)
71+
72+
print("\n>>> With cache (reuse credential instance)...")
73+
cache_times = bench_with_cache(N)
74+
report("WITH credential cache (new behavior)", cache_times)
75+
76+
speedup = statistics.mean(no_cache_times) / statistics.mean(cache_times)
77+
saved = (statistics.mean(no_cache_times) - statistics.mean(cache_times)) * 1000
78+
print(f"\n{'=' * 50}")
79+
print(f" SPEEDUP: {speedup:.1f}x ({saved:.0f}ms saved per call)")
80+
print(f"{'=' * 50}")
81+
except Exception as e:
82+
print(f"\nBenchmark failed: {e}")
83+
print("Make sure you are logged in via 'az login' and have azure-identity installed.")
84+
85+
86+
if __name__ == "__main__":
87+
main()

mssql_python/__init__.py

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,221 @@ def _cleanup_connections():
293293
SQL_IC_MIXED,
294294
)
295295

296+
__all__ = [
297+
# Exception classes
298+
"Warning",
299+
"Error",
300+
"InterfaceError",
301+
"DatabaseError",
302+
"DataError",
303+
"OperationalError",
304+
"IntegrityError",
305+
"InternalError",
306+
"ProgrammingError",
307+
"NotSupportedError",
308+
"ConnectionStringParseError",
309+
# Type objects and functions
310+
"Date",
311+
"Time",
312+
"Timestamp",
313+
"DateFromTicks",
314+
"TimeFromTicks",
315+
"TimestampFromTicks",
316+
"Binary",
317+
"STRING",
318+
"BINARY",
319+
"NUMBER",
320+
"DATETIME",
321+
"ROWID",
322+
# Connection and cursor classes
323+
"connect",
324+
"Connection",
325+
"Cursor",
326+
"Row",
327+
# Settings
328+
"Settings",
329+
"get_settings",
330+
# Logging
331+
"logger",
332+
"setup_logging",
333+
"driver_logger",
334+
# Decimal functions
335+
"setDecimalSeparator",
336+
"getDecimalSeparator",
337+
# Pooling
338+
"pooling",
339+
"PoolingManager",
340+
# Constants - Enum classes
341+
"AuthType",
342+
"SQLTypes",
343+
"get_info_constants",
344+
# SQL Type constants
345+
"SQL_CHAR",
346+
"SQL_VARCHAR",
347+
"SQL_LONGVARCHAR",
348+
"SQL_WCHAR",
349+
"SQL_WVARCHAR",
350+
"SQL_WLONGVARCHAR",
351+
"SQL_DECIMAL",
352+
"SQL_NUMERIC",
353+
"SQL_BIT",
354+
"SQL_TINYINT",
355+
"SQL_SMALLINT",
356+
"SQL_INTEGER",
357+
"SQL_BIGINT",
358+
"SQL_REAL",
359+
"SQL_FLOAT",
360+
"SQL_DOUBLE",
361+
"SQL_BINARY",
362+
"SQL_VARBINARY",
363+
"SQL_LONGVARBINARY",
364+
"SQL_DATE",
365+
"SQL_TIME",
366+
"SQL_TIMESTAMP",
367+
"SQL_TYPE_DATE",
368+
"SQL_TYPE_TIME",
369+
"SQL_TYPE_TIMESTAMP",
370+
"SQL_GUID",
371+
"SQL_XML",
372+
# Connection attribute constants
373+
"SQL_ATTR_ACCESS_MODE",
374+
"SQL_ATTR_CONNECTION_TIMEOUT",
375+
"SQL_ATTR_CURRENT_CATALOG",
376+
"SQL_ATTR_LOGIN_TIMEOUT",
377+
"SQL_ATTR_PACKET_SIZE",
378+
"SQL_ATTR_TXN_ISOLATION",
379+
# Transaction isolation levels
380+
"SQL_TXN_READ_UNCOMMITTED",
381+
"SQL_TXN_READ_COMMITTED",
382+
"SQL_TXN_REPEATABLE_READ",
383+
"SQL_TXN_SERIALIZABLE",
384+
# Access modes
385+
"SQL_MODE_READ_WRITE",
386+
"SQL_MODE_READ_ONLY",
387+
# Special constants
388+
"SQL_WMETADATA",
389+
# GetInfo constants
390+
"SQL_DRIVER_NAME",
391+
"SQL_DRIVER_VER",
392+
"SQL_DRIVER_ODBC_VER",
393+
"SQL_DRIVER_HLIB",
394+
"SQL_DRIVER_HENV",
395+
"SQL_DRIVER_HDBC",
396+
"SQL_DATA_SOURCE_NAME",
397+
"SQL_DATABASE_NAME",
398+
"SQL_SERVER_NAME",
399+
"SQL_USER_NAME",
400+
"SQL_SQL_CONFORMANCE",
401+
"SQL_KEYWORDS",
402+
"SQL_IDENTIFIER_CASE",
403+
"SQL_IDENTIFIER_QUOTE_CHAR",
404+
"SQL_SPECIAL_CHARACTERS",
405+
"SQL_SQL92_ENTRY_SQL",
406+
"SQL_SQL92_INTERMEDIATE_SQL",
407+
"SQL_SQL92_FULL_SQL",
408+
"SQL_SUBQUERIES",
409+
"SQL_EXPRESSIONS_IN_ORDERBY",
410+
"SQL_CORRELATION_NAME",
411+
"SQL_SEARCH_PATTERN_ESCAPE",
412+
"SQL_CATALOG_TERM",
413+
"SQL_CATALOG_NAME_SEPARATOR",
414+
"SQL_SCHEMA_TERM",
415+
"SQL_TABLE_TERM",
416+
"SQL_PROCEDURES",
417+
"SQL_ACCESSIBLE_TABLES",
418+
"SQL_ACCESSIBLE_PROCEDURES",
419+
"SQL_CATALOG_NAME",
420+
"SQL_CATALOG_USAGE",
421+
"SQL_SCHEMA_USAGE",
422+
"SQL_COLUMN_ALIAS",
423+
"SQL_DESCRIBE_PARAMETER",
424+
"SQL_TXN_CAPABLE",
425+
"SQL_TXN_ISOLATION_OPTION",
426+
"SQL_DEFAULT_TXN_ISOLATION",
427+
"SQL_MULTIPLE_ACTIVE_TXN",
428+
"SQL_TXN_ISOLATION_LEVEL",
429+
"SQL_NUMERIC_FUNCTIONS",
430+
"SQL_STRING_FUNCTIONS",
431+
"SQL_DATETIME_FUNCTIONS",
432+
"SQL_SYSTEM_FUNCTIONS",
433+
"SQL_CONVERT_FUNCTIONS",
434+
"SQL_LIKE_ESCAPE_CLAUSE",
435+
"SQL_MAX_COLUMN_NAME_LEN",
436+
"SQL_MAX_TABLE_NAME_LEN",
437+
"SQL_MAX_SCHEMA_NAME_LEN",
438+
"SQL_MAX_CATALOG_NAME_LEN",
439+
"SQL_MAX_IDENTIFIER_LEN",
440+
"SQL_MAX_STATEMENT_LEN",
441+
"SQL_MAX_CHAR_LITERAL_LEN",
442+
"SQL_MAX_BINARY_LITERAL_LEN",
443+
"SQL_MAX_COLUMNS_IN_TABLE",
444+
"SQL_MAX_COLUMNS_IN_SELECT",
445+
"SQL_MAX_COLUMNS_IN_GROUP_BY",
446+
"SQL_MAX_COLUMNS_IN_ORDER_BY",
447+
"SQL_MAX_COLUMNS_IN_INDEX",
448+
"SQL_MAX_TABLES_IN_SELECT",
449+
"SQL_MAX_CONCURRENT_ACTIVITIES",
450+
"SQL_MAX_DRIVER_CONNECTIONS",
451+
"SQL_MAX_ROW_SIZE",
452+
"SQL_MAX_USER_NAME_LEN",
453+
"SQL_ACTIVE_CONNECTIONS",
454+
"SQL_ACTIVE_STATEMENTS",
455+
"SQL_DATA_SOURCE_READ_ONLY",
456+
"SQL_NEED_LONG_DATA_LEN",
457+
"SQL_GETDATA_EXTENSIONS",
458+
"SQL_CURSOR_COMMIT_BEHAVIOR",
459+
"SQL_CURSOR_ROLLBACK_BEHAVIOR",
460+
"SQL_CURSOR_SENSITIVITY",
461+
"SQL_BOOKMARK_PERSISTENCE",
462+
"SQL_DYNAMIC_CURSOR_ATTRIBUTES1",
463+
"SQL_DYNAMIC_CURSOR_ATTRIBUTES2",
464+
"SQL_FORWARD_ONLY_CURSOR_ATTRIBUTES1",
465+
"SQL_FORWARD_ONLY_CURSOR_ATTRIBUTES2",
466+
"SQL_STATIC_CURSOR_ATTRIBUTES1",
467+
"SQL_STATIC_CURSOR_ATTRIBUTES2",
468+
"SQL_KEYSET_CURSOR_ATTRIBUTES1",
469+
"SQL_KEYSET_CURSOR_ATTRIBUTES2",
470+
"SQL_SCROLL_OPTIONS",
471+
"SQL_SCROLL_CONCURRENCY",
472+
"SQL_FETCH_DIRECTION",
473+
"SQL_ROWSET_SIZE",
474+
"SQL_CONCURRENCY",
475+
"SQL_ROW_NUMBER",
476+
"SQL_STATIC_SENSITIVITY",
477+
"SQL_BATCH_SUPPORT",
478+
"SQL_BATCH_ROW_COUNT",
479+
"SQL_PARAM_ARRAY_ROW_COUNTS",
480+
"SQL_PARAM_ARRAY_SELECTS",
481+
"SQL_PROCEDURE_TERM",
482+
"SQL_POSITIONED_STATEMENTS",
483+
"SQL_GROUP_BY",
484+
"SQL_OJ_CAPABILITIES",
485+
"SQL_ORDER_BY_COLUMNS_IN_SELECT",
486+
"SQL_OUTER_JOINS",
487+
"SQL_QUOTED_IDENTIFIER_CASE",
488+
"SQL_CONCAT_NULL_BEHAVIOR",
489+
"SQL_NULL_COLLATION",
490+
"SQL_ALTER_TABLE",
491+
"SQL_UNION",
492+
"SQL_DDL_INDEX",
493+
"SQL_MULT_RESULT_SETS",
494+
"SQL_OWNER_USAGE",
495+
"SQL_QUALIFIER_USAGE",
496+
"SQL_TIMEDATE_ADD_INTERVALS",
497+
"SQL_TIMEDATE_DIFF_INTERVALS",
498+
"SQL_IC_UPPER",
499+
"SQL_IC_LOWER",
500+
"SQL_IC_SENSITIVE",
501+
"SQL_IC_MIXED",
502+
# API level globals
503+
"apilevel",
504+
"paramstyle",
505+
"threadsafety",
506+
# Module properties
507+
"lowercase",
508+
"native_uuid",
509+
]
510+
296511

297512
def pooling(max_size: int = 100, idle_timeout: int = 600, enabled: bool = True) -> None:
298513
"""

mssql_python/auth.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,19 @@
66

77
import platform
88
import struct
9+
import threading
910
from typing import Tuple, Dict, Optional, List
1011

1112
from mssql_python.logging import logger
1213
from mssql_python.constants import AuthType, ConstantsDDBC
1314

15+
# Module-level credential instance cache.
16+
# Reusing credential objects allows the Azure Identity SDK's built-in
17+
# in-memory token cache to work, avoiding redundant token acquisitions.
18+
# See: https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/identity/azure-identity/TOKEN_CACHING.md
19+
_credential_cache: Dict[str, object] = {}
20+
_credential_cache_lock = threading.Lock()
21+
1422

1523
class AADAuth:
1624
"""Handles Azure Active Directory authentication"""
@@ -36,12 +44,11 @@ def get_token(auth_type: str) -> bytes:
3644

3745
@staticmethod
3846
def get_raw_token(auth_type: str) -> str:
39-
"""Acquire a fresh raw JWT for the mssql-py-core connection (bulk copy).
47+
"""Acquire a raw JWT for the mssql-py-core connection (bulk copy).
4048
41-
This deliberately does NOT cache the credential or token — each call
42-
creates a new Azure Identity credential instance and requests a token.
43-
A fresh acquisition avoids expired-token errors when bulkcopy() is
44-
called long after the original DDBC connect().
49+
Uses the cached credential instance so the Azure Identity SDK's
50+
built-in token cache can serve a valid token without a round-trip
51+
when the previous token has not yet expired.
4552
"""
4653
_, raw_token = AADAuth._acquire_token(auth_type)
4754
return raw_token
@@ -83,7 +90,19 @@ def _acquire_token(auth_type: str) -> Tuple[bytes, str]:
8390
)
8491

8592
try:
86-
credential = credential_class()
93+
with _credential_cache_lock:
94+
if auth_type not in _credential_cache:
95+
logger.debug(
96+
"get_token: Creating new credential instance for auth_type=%s",
97+
auth_type,
98+
)
99+
_credential_cache[auth_type] = credential_class()
100+
else:
101+
logger.debug(
102+
"get_token: Reusing cached credential instance for auth_type=%s",
103+
auth_type,
104+
)
105+
credential = _credential_cache[auth_type]
87106
raw_token = credential.get_token("https://database.windows.net/.default").token
88107
logger.info(
89108
"get_token: Azure AD token acquired successfully - token_length=%d chars",

mssql_python/cursor.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2852,6 +2852,10 @@ def bulkcopy(
28522852
f"for auth_type '{self.connection._auth_type}': {e}"
28532853
) from e
28542854
pycore_context["access_token"] = raw_token
2855+
# Token replaces credential fields — py-core's validator rejects
2856+
# access_token combined with authentication/user_name/password.
2857+
for key in ("authentication", "user_name", "password"):
2858+
pycore_context.pop(key, None)
28552859
logger.debug(
28562860
"Bulk copy: acquired fresh Azure AD token for auth_type=%s",
28572861
self.connection._auth_type,

0 commit comments

Comments
 (0)