|
22 | 22 | import pytest |
23 | 23 | import time |
24 | 24 | from mssql_python import Connection, connect, pooling |
| 25 | +import threading |
25 | 26 |
|
26 | 27 | def drop_table_if_exists(cursor, table_name): |
27 | 28 | """Drop the table if it exists""" |
@@ -225,31 +226,245 @@ def test_connection_close(conn_str): |
225 | 226 | temp_conn.close() |
226 | 227 |
|
227 | 228 | def test_connection_pooling_speed(conn_str): |
228 | | - # No pooling |
229 | | - start_no_pool = time.perf_counter() |
| 229 | + # Disable pooling first |
| 230 | + pooling(enabled=False) |
| 231 | + |
| 232 | + # Warm up - establish initial connection to avoid first-connection overhead |
| 233 | + warmup = connect(conn_str) |
| 234 | + warmup.close() |
| 235 | + |
| 236 | + # Measure multiple non-pooled connections |
| 237 | + non_pooled_times = [] |
| 238 | + for _ in range(5): |
| 239 | + start = time.perf_counter() |
| 240 | + conn = connect(conn_str) |
| 241 | + conn.close() |
| 242 | + end = time.perf_counter() |
| 243 | + non_pooled_times.append(end - start) |
| 244 | + |
| 245 | + avg_no_pool = sum(non_pooled_times) / len(non_pooled_times) |
| 246 | + |
| 247 | + # Enable pooling |
| 248 | + pooling(max_size=5, idle_timeout=30) |
| 249 | + |
| 250 | + # Prime the pool with a connection |
| 251 | + primer = connect(conn_str) |
| 252 | + primer.close() |
| 253 | + |
| 254 | + # Small delay to ensure connection is properly returned to pool |
| 255 | + time.sleep(0.1) |
| 256 | + |
| 257 | + # Measure multiple pooled connections |
| 258 | + pooled_times = [] |
| 259 | + for _ in range(5): |
| 260 | + start = time.perf_counter() |
| 261 | + conn = connect(conn_str) |
| 262 | + conn.close() |
| 263 | + end = time.perf_counter() |
| 264 | + pooled_times.append(end - start) |
| 265 | + |
| 266 | + avg_pooled = sum(pooled_times) / len(pooled_times) |
| 267 | + |
| 268 | + # Pooled should be significantly faster than non-pooled |
| 269 | + assert avg_pooled < avg_no_pool, \ |
| 270 | + f"Pooled connections ({avg_pooled:.6f}s) not significantly faster than non-pooled ({avg_no_pool:.6f}s)" |
| 271 | + |
| 272 | + # Clean up - disable pooling for other tests |
| 273 | + pooling(enabled=False) |
| 274 | + |
| 275 | +def test_connection_pooling_reuse_spid(conn_str): |
| 276 | + """Test that connections are actually reused from the pool""" |
| 277 | + # Enable pooling |
| 278 | + pooling(max_size=1, idle_timeout=30) |
| 279 | + |
| 280 | + # Create and close a connection |
230 | 281 | conn1 = connect(conn_str) |
| 282 | + cursor1 = conn1.cursor() |
| 283 | + cursor1.execute("SELECT @@SPID") # Get SQL Server process ID |
| 284 | + spid1 = cursor1.fetchone()[0] |
231 | 285 | conn1.close() |
232 | | - end_no_pool = time.perf_counter() |
233 | | - no_pool_duration = end_no_pool - start_no_pool |
234 | | - |
235 | | - # Second connection |
236 | | - start2 = time.perf_counter() |
| 286 | + |
| 287 | + # Get another connection - should be the same one from pool |
237 | 288 | conn2 = connect(conn_str) |
| 289 | + cursor2 = conn2.cursor() |
| 290 | + cursor2.execute("SELECT @@SPID") |
| 291 | + spid2 = cursor2.fetchone()[0] |
238 | 292 | conn2.close() |
239 | | - end2 = time.perf_counter() |
240 | | - duration2 = end2 - start2 |
| 293 | + |
| 294 | + # The SPID should be the same, indicating connection reuse |
| 295 | + assert spid1 == spid2, "Connections not reused - different SPIDs" |
| 296 | + |
| 297 | + # Clean up |
| 298 | + |
| 299 | +def test_pool_exhaustion_max_size_1(conn_str): |
| 300 | + """Test pool exhaustion when max_size=1 and multiple concurrent connections are requested.""" |
| 301 | + pooling(max_size=1, idle_timeout=30) |
| 302 | + conn1 = connect(conn_str) |
| 303 | + results = [] |
| 304 | + |
| 305 | + def try_connect(): |
| 306 | + try: |
| 307 | + conn2 = connect(conn_str) |
| 308 | + results.append("success") |
| 309 | + conn2.close() |
| 310 | + except Exception as e: |
| 311 | + results.append(str(e)) |
| 312 | + |
| 313 | + # Start a thread that will attempt to get a second connection while the first is open |
| 314 | + t = threading.Thread(target=try_connect) |
| 315 | + t.start() |
| 316 | + t.join(timeout=2) |
| 317 | + conn1.close() |
| 318 | + |
| 319 | + # Depending on implementation, either blocks, raises, or times out |
| 320 | + assert results, "Second connection attempt did not complete" |
| 321 | + # If pool blocks, the thread may not finish until conn1 is closed, so allow both outcomes |
| 322 | + assert results[0] == "success" or "pool" in results[0].lower() or "timeout" in results[0].lower(), \ |
| 323 | + f"Unexpected pool exhaustion result: {results[0]}" |
| 324 | + pooling(enabled=False) |
| 325 | + |
| 326 | +def test_pool_idle_timeout_removes_connections(conn_str): |
| 327 | + """Test that idle_timeout removes connections from the pool after the timeout.""" |
| 328 | + pooling(max_size=2, idle_timeout=2) |
| 329 | + conn1 = connect(conn_str) |
| 330 | + spid_list = [] |
| 331 | + cursor1 = conn1.cursor() |
| 332 | + cursor1.execute("SELECT @@SPID") |
| 333 | + spid1 = cursor1.fetchone()[0] |
| 334 | + spid_list.append(spid1) |
| 335 | + conn1.close() |
241 | 336 |
|
242 | | - # Pooling enabled |
243 | | - pooling(max_size=2, idle_timeout=10) |
244 | | - connect(conn_str).close() |
| 337 | + # Wait for longer than idle_timeout |
| 338 | + time.sleep(3) |
245 | 339 |
|
246 | | - # Pooled connection (should be reused, hence faster) |
247 | | - start_pool = time.perf_counter() |
| 340 | + # Get a new connection, which should not reuse the previous SPID |
248 | 341 | conn2 = connect(conn_str) |
| 342 | + cursor2 = conn2.cursor() |
| 343 | + cursor2.execute("SELECT @@SPID") |
| 344 | + spid2 = cursor2.fetchone()[0] |
| 345 | + spid_list.append(spid2) |
249 | 346 | conn2.close() |
250 | | - end_pool = time.perf_counter() |
251 | | - pool_duration = end_pool - start_pool |
252 | | - assert pool_duration < no_pool_duration, "Expected faster connection with pooling" |
| 347 | + |
| 348 | + assert spid1 != spid2, "Idle timeout did not remove connection from pool" |
| 349 | + |
| 350 | +def test_connection_timeout_invalid_password(conn_str): |
| 351 | + """Test that connecting with an invalid password raises an exception quickly (timeout).""" |
| 352 | + # Modify the connection string to use an invalid password |
| 353 | + if "Pwd=" in conn_str: |
| 354 | + bad_conn_str = conn_str.replace("Pwd=", "Pwd=wrongpassword") |
| 355 | + elif "Password=" in conn_str: |
| 356 | + bad_conn_str = conn_str.replace("Password=", "Password=wrongpassword") |
| 357 | + else: |
| 358 | + pytest.skip("No password found in connection string to modify") |
| 359 | + start = time.perf_counter() |
| 360 | + with pytest.raises(Exception): |
| 361 | + connect(bad_conn_str) |
| 362 | + elapsed = time.perf_counter() - start |
| 363 | + # Should fail quickly (within 10 seconds) |
| 364 | + assert elapsed < 10, f"Connection with invalid password took too long: {elapsed:.2f}s" |
| 365 | + |
| 366 | +def test_connection_timeout_invalid_host(conn_str): |
| 367 | + """Test that connecting to an invalid host fails with a timeout.""" |
| 368 | + # Replace server/host with an invalid one |
| 369 | + if "Server=" in conn_str: |
| 370 | + bad_conn_str = conn_str.replace("Server=", "Server=invalidhost12345;") |
| 371 | + elif "host=" in conn_str: |
| 372 | + bad_conn_str = conn_str.replace("host=", "host=invalidhost12345;") |
| 373 | + else: |
| 374 | + pytest.skip("No server/host found in connection string to modify") |
| 375 | + start = time.perf_counter() |
| 376 | + with pytest.raises(Exception): |
| 377 | + connect(bad_conn_str) |
| 378 | + elapsed = time.perf_counter() - start |
| 379 | + # Should fail within a reasonable time (30s) |
| 380 | + # Note: This may vary based on network conditions, so adjust as needed |
| 381 | + # but generally, a connection to an invalid host should not take too long |
| 382 | + # to fail. |
| 383 | + # If it takes too long, it may indicate a misconfiguration or network issue. |
| 384 | + assert elapsed < 30, f"Connection to invalid host took too long: {elapsed:.2f}s" |
| 385 | + |
| 386 | +def test_pool_removes_invalid_connections(conn_str): |
| 387 | + """Test that the pool removes connections that become invalid (simulate by closing underlying connection).""" |
| 388 | + pooling(max_size=1, idle_timeout=30) |
| 389 | + conn = connect(conn_str) |
| 390 | + cursor = conn.cursor() |
| 391 | + cursor.execute("SELECT 1") |
| 392 | + # Simulate invalidation by forcibly closing the connection at the driver level |
| 393 | + try: |
| 394 | + # Try to access a private attribute or method to forcibly close the underlying connection |
| 395 | + # This is implementation-specific; if not possible, skip |
| 396 | + if hasattr(conn, "_conn") and hasattr(conn._conn, "close"): |
| 397 | + conn._conn.close() |
| 398 | + else: |
| 399 | + pytest.skip("Cannot forcibly close underlying connection for this driver") |
| 400 | + except Exception: |
| 401 | + pass |
| 402 | + # Safely close the connection, ignoring errors due to forced invalidation |
| 403 | + try: |
| 404 | + conn.close() |
| 405 | + except RuntimeError as e: |
| 406 | + if "not initialized" not in str(e): |
| 407 | + raise |
| 408 | + # Now, get a new connection from the pool and ensure it works |
| 409 | + new_conn = connect(conn_str) |
| 410 | + new_cursor = new_conn.cursor() |
| 411 | + try: |
| 412 | + new_cursor.execute("SELECT 1") |
| 413 | + result = new_cursor.fetchone() |
| 414 | + assert result is not None and result[0] == 1, "Pool did not remove invalid connection" |
| 415 | + finally: |
| 416 | + new_conn.close() |
| 417 | + pooling(enabled=False) |
| 418 | + |
| 419 | +def test_pool_recovery_after_failed_connection(conn_str): |
| 420 | + """Test that the pool recovers after a failed connection attempt.""" |
| 421 | + pooling(max_size=1, idle_timeout=30) |
| 422 | + # First, try to connect with a bad password (should fail) |
| 423 | + if "Pwd=" in conn_str: |
| 424 | + bad_conn_str = conn_str.replace("Pwd=", "Pwd=wrongpassword") |
| 425 | + elif "Password=" in conn_str: |
| 426 | + bad_conn_str = conn_str.replace("Password=", "Password=wrongpassword") |
| 427 | + else: |
| 428 | + pytest.skip("No password found in connection string to modify") |
| 429 | + with pytest.raises(Exception): |
| 430 | + connect(bad_conn_str) |
| 431 | + # Now, connect with the correct string and ensure it works |
| 432 | + conn = connect(conn_str) |
| 433 | + cursor = conn.cursor() |
| 434 | + cursor.execute("SELECT 1") |
| 435 | + result = cursor.fetchone() |
| 436 | + assert result is not None and result[0] == 1, "Pool did not recover after failed connection" |
| 437 | + conn.close() |
| 438 | + pooling(enabled=False) |
| 439 | + |
| 440 | +def test_pool_capacity_limit_and_overflow(conn_str): |
| 441 | + """Test that pool does not grow beyond max_size and handles overflow gracefully.""" |
| 442 | + pooling(max_size=2, idle_timeout=30) |
| 443 | + conns = [] |
| 444 | + try: |
| 445 | + # Open up to max_size connections |
| 446 | + conns.append(connect(conn_str)) |
| 447 | + conns.append(connect(conn_str)) |
| 448 | + # Try to open a third connection, which should fail or block |
| 449 | + overflow_result = [] |
| 450 | + def try_overflow(): |
| 451 | + try: |
| 452 | + c = connect(conn_str) |
| 453 | + overflow_result.append("success") |
| 454 | + c.close() |
| 455 | + except Exception as e: |
| 456 | + overflow_result.append(str(e)) |
| 457 | + t = threading.Thread(target=try_overflow) |
| 458 | + t.start() |
| 459 | + t.join(timeout=2) |
| 460 | + assert overflow_result, "Overflow connection attempt did not complete" |
| 461 | + # Accept either block, error, or success if pool implementation allows overflow |
| 462 | + assert overflow_result[0] == "success" or "pool" in overflow_result[0].lower() or "timeout" in overflow_result[0].lower(), \ |
| 463 | + f"Unexpected pool overflow result: {overflow_result[0]}" |
| 464 | + finally: |
| 465 | + for c in conns: |
| 466 | + c.close() |
| 467 | + pooling(enabled=False) |
253 | 468 |
|
254 | 469 | def test_connection_pooling_basic(conn_str): |
255 | 470 | # Enable pooling with small pool size |
|
0 commit comments