diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs index 77d2b3bae1..da2246d8aa 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs @@ -383,6 +383,41 @@ internal WaitHandle[] GetHandles(bool withCreate) } } + /// + /// Helper class to obtain and release a semaphore. + /// + internal class SemaphoreHolder : IDisposable + { + private readonly Semaphore _semaphore; + + /// + /// Whether the semaphore was successfully obtained within the timeout. + /// + internal bool Obtained { get; private set; } + + /// + /// Obtains the semaphore, waiting up to the specified timeout. + /// + /// + /// + internal SemaphoreHolder(Semaphore semaphore, int timeout) + { + _semaphore = semaphore; + Obtained = _semaphore.WaitOne(timeout); + } + + /// + /// Releases the semaphore if it was successfully obtained. + /// + public void Dispose() + { + if (Obtained) + { + _semaphore.Release(1); + } + } + } + private const int MAX_Q_SIZE = 0x00100000; // The order of these is important; we want the WaitAny call to be signaled @@ -1321,20 +1356,14 @@ private bool TryGetConnection(DbConnection owningObject, uint waitForMultipleObj if (onlyOneCheckConnection) { - if (_waitHandles.CreationSemaphore.WaitOne(unchecked((int)waitForMultipleObjectsTimeout))) + using SemaphoreHolder semaphoreHolder = new(_waitHandles.CreationSemaphore, unchecked((int)waitForMultipleObjectsTimeout)); + if (semaphoreHolder.Obtained) { #if NETFRAMEWORK RuntimeHelpers.PrepareConstrainedRegions(); #endif - try - { - SqlClientEventSource.Log.TryPoolerTraceEvent(" {0}, Creating new connection.", Id); - obj = UserCreateRequest(owningObject, userOptions); - } - finally - { - _waitHandles.CreationSemaphore.Release(1); - } + SqlClientEventSource.Log.TryPoolerTraceEvent(" {0}, Creating new connection.", Id); + obj = UserCreateRequest(owningObject, userOptions); } else { @@ -1553,7 +1582,6 @@ private void PoolCreateRequest(object state) { return; } - int waitResult = BOGUS_HANDLE; #if NETFRAMEWORK RuntimeHelpers.PrepareConstrainedRegions(); @@ -1561,18 +1589,13 @@ private void PoolCreateRequest(object state) try { // Obtain creation mutex so we're the only one creating objects - // and we must have the wait result + using SemaphoreHolder semaphoreHolder = new(_waitHandles.CreationSemaphore, CreationTimeout); + + if (semaphoreHolder.Obtained) + { #if NETFRAMEWORK RuntimeHelpers.PrepareConstrainedRegions(); #endif - try - { } - finally - { - waitResult = WaitHandle.WaitAny(_waitHandles.GetHandles(withCreate: true), CreationTimeout); - } - if (CREATION_HANDLE == waitResult) - { DbConnectionInternal newObj; // Check ErrorOccurred again after obtaining mutex @@ -1606,17 +1629,12 @@ private void PoolCreateRequest(object state) } } } - else if (WaitHandle.WaitTimeout == waitResult) + else { // do not wait forever and potential block this worker thread // instead wait for a period of time and just requeue to try again QueuePoolCreateRequest(); } - else - { - // trace waitResult and ignore the failure - SqlClientEventSource.Log.TryPoolerTraceEvent(" {0}, PoolCreateRequest called WaitForSingleObject failed {1}", Id, waitResult); - } } catch (Exception e) { @@ -1630,14 +1648,6 @@ private void PoolCreateRequest(object state) // thrown to the user the next time they request a connection. SqlClientEventSource.Log.TryPoolerTraceEvent(" {0}, PoolCreateRequest called CreateConnection which threw an exception: {1}", Id, e); } - finally - { - if (CREATION_HANDLE == waitResult) - { - // reuse waitResult and ignore its value - _waitHandles.CreationSemaphore.Release(1); - } - } } } } diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/Microsoft.Data.SqlClient.ManualTesting.Tests.csproj b/src/Microsoft.Data.SqlClient/tests/ManualTests/Microsoft.Data.SqlClient.ManualTesting.Tests.csproj index 0da63efa61..fed317e720 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/Microsoft.Data.SqlClient.ManualTesting.Tests.csproj +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/Microsoft.Data.SqlClient.ManualTesting.Tests.csproj @@ -258,6 +258,7 @@ + diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/ConnectionPoolTest/ConnectionPoolStressTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/ConnectionPoolTest/ConnectionPoolStressTest.cs new file mode 100644 index 0000000000..d3d41d4c9a --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/ConnectionPoolTest/ConnectionPoolStressTest.cs @@ -0,0 +1,440 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +#nullable enable + +namespace Microsoft.Data.SqlClient.ManualTesting.Tests +{ + /// + /// Connection pool stress test to validate pool behavior under various concurrent load scenarios. + /// + public class ConnectionPoolStressTest + { + #region Properties + + /// + /// Connection string + /// + internal string? ConnectionString { get; set; } + + /// + /// Maximum number of connections in the pool + /// + public int MaxPoolSize { get; set; } = 100; + + /// + /// SQL WAITFOR DELAY value for simulating slow queries + /// + public string WaitForDelay { get; set; } = "00:00:00.100"; + + /// + /// Number of concurrent connections to create + /// + public int ConcurrentConnections { get; set; } = 10; + + /// + /// Number of operations each thread should perform + /// + public int OperationsPerThread { get; set; } = 10; + + #endregion + + #region Connection Dooming + + // Reflection fields for accessing internal connection properties + private readonly FieldInfo? _internalConnectionField; + + public ConnectionPoolStressTest() + { + try + { + // Cache reflection info for Microsoft.Data.SqlClient + Type msDataConnectionType = typeof(SqlConnection); + _internalConnectionField = msDataConnectionType.GetField("_innerConnection", BindingFlags.NonPublic | BindingFlags.Instance); + } + catch (Exception ex) + { + Console.WriteLine($"Warning: Failed to initialize reflection for connection dooming: {ex.Message}"); + } + } + + /// + /// Dooms a Microsoft.Data.SqlClient connection by calling DoomThisConnection on its internal connection + /// + private bool DoomMicrosoftDataConnection(SqlConnection connection) + { + try + { + if(_internalConnectionField == null) + { + // Fail the test if reflection setup failed + return false; + } + + if (_internalConnectionField.GetValue(connection) is object internalConnection) + { + MethodInfo? doomMethod = internalConnection.GetType().GetMethod("DoomThisConnection", BindingFlags.NonPublic | BindingFlags.Instance); + if (doomMethod != null) + { + doomMethod.Invoke(internalConnection, null); + return true; + } + else + { + return false; + } + } + else + { + return false; + } + } + catch (Exception) + { + return false; + } + } + + #endregion + + #region Configuration + + /// + /// Sets up connection string + /// + /// Connection string to be set. + internal void SetConnectionString(string connectionString) + { + var connectionSB = new SqlConnectionStringBuilder(connectionString) + { + // Min size needs to be larger than the number of concurrent connections to trigger the pool exhaustion as it will make it more likely that PoolCreateRequest will run. + MinPoolSize = Math.Min(20, MaxPoolSize / 5), // Dynamic min pool size + MaxPoolSize = MaxPoolSize, + Pooling = true, // Explicitly enable pooling + TrustServerCertificate = true + }; + + ConnectionString = connectionSB.ConnectionString; + + // Ensure adequate thread pool capacity + ThreadPool.SetMaxThreads(Math.Max(ConcurrentConnections * 2, 100), 100); + } + + #endregion + + #region Stress Test Methods + + /// + /// Runs a synchronous stress test using Microsoft.Data.SqlClient with connection dooming + /// + internal void ConnectionPoolStress_MsData_Sync() + { + if (ConnectionString == null) + { + throw new InvalidOperationException("ConnectionString is not set. Call SetConnectionString() before running the test."); + } + + RunStressTest( + connectionString: ConnectionString, + doomAction: conn => DoomMicrosoftDataConnection((SqlConnection)conn), + async: false + ); + } + + /// + /// Runs asynchronous stress test using Microsoft.Data.SqlClient with connection dooming + /// + internal void ConnectionPoolStress_MsData_Async() + { + if (ConnectionString == null) + { + throw new InvalidOperationException("ConnectionString is not set. Call SetConnectionString() before running the test."); + } + + RunStressTest( + connectionString: ConnectionString, + doomAction: conn => DoomMicrosoftDataConnection((SqlConnection)conn), + async: true + ); + } + + /// + /// Generic stress test method that works with both SQL client libraries using DbConnection/DbCommand + /// + private void RunStressTest( + string connectionString, + Func doomAction, + bool async = false) + { + var threads = new Thread[ConcurrentConnections]; + using Barrier barrier = new(ConcurrentConnections); + using CountdownEvent countdown = new(ConcurrentConnections); + + var command = string.IsNullOrWhiteSpace(WaitForDelay) + ? "SELECT GETDATE()" + : $"WAITFOR DELAY '{WaitForDelay}'; SELECT GETDATE()"; + + // Create regular threads (don't doom connections) + for (int i = 0; i < ConcurrentConnections - 1; i++) + { + threads[i] = CreateWorkerThread( + connectionString, command, barrier, countdown, doomConnections: false, async); + } + + // Create special thread that dooms connections (if we have multiple threads) + if (ConcurrentConnections > 1) + { + threads[ConcurrentConnections - 1] = CreateWorkerThread( + connectionString, command, barrier, countdown, doomConnections: true, async, doomAction); + } + + // Start all threads + foreach (Thread thread in threads.Where(t => t != null)) + { + thread.Start(); + } + + // Wait for completion + countdown.Wait(); + } + + /// + /// Creates a worker thread that performs database operations using DbConnection/DbCommand + /// + private Thread CreateWorkerThread( + string connectionString, + string command, + Barrier barrier, + CountdownEvent countdown, + bool doomConnections, + bool async, + Func? doomAction = null) + { + return new Thread(async () => + { + try + { + barrier.SignalAndWait(); // Initial synchronization - all threads start together + + for (int j = 0; j < OperationsPerThread; j++) + { + if (doomConnections && doomAction != null) + { + // Dooming thread - barriers inside using block to doom before disposal + using var conn = new SqlConnection(connectionString); + if (async) + { + await conn.OpenAsync(); + } + else + { + conn.Open(); + } + + await ExecuteCommand(command, async, conn); + + // Synchronize after command execution, before dooming + barrier.SignalAndWait(); + + // Doom connection before it gets disposed/returned to pool + if (!doomAction(conn)) + { + throw new Exception("Unable to doom connection"); + } + + // Synchronize after dooming - ensures all threads see the effect + barrier.SignalAndWait(); + } + else + { + // Non-dooming threads - barriers after connection is closed + using (var conn = new SqlConnection(connectionString)) + { + if (async) + { + await conn.OpenAsync(); + } + else + { + conn.Open(); + } + + await ExecuteCommand(command, async, conn); + + } // Connection is closed/returned to pool here + + // Synchronize after connection is closed + barrier.SignalAndWait(); + + // Sync for coordination with dooming thread + barrier.SignalAndWait(); + } + } + } + finally + { + countdown.Signal(); + } + }) + { + IsBackground = true // Make threads background threads for cleaner shutdown + }; + } + + /// + /// Executes a database command with proper error handling + /// + private static async Task ExecuteCommand(string command, bool async, SqlConnection conn) + { + try + { + using var cmd = new SqlCommand(command, conn); + if (async) + { + await cmd.ExecuteScalarAsync(); + } + else + { + cmd.ExecuteScalar(); + } + } + catch (Exception ex) + { + Console.WriteLine($"Command execution failed: {ex.Message}"); + } + } + + #endregion + + #region Helpers + + private static bool RunSingleStressTest(Action testAction) + { + try + { + var stopwatch = Stopwatch.StartNew(); + testAction(); + stopwatch.Stop(); + } + catch (Exception ex) + { + if (ex.InnerException != null) + { + return false; + } + } + + return true; + } + + private static async Task TestConnectionPoolExhaustion(string connectionString, int maxPoolSize, bool async) + { + var connections = new List(); + + try + { + for (int i = 0; i < maxPoolSize; i++) + { + SqlConnection conn = new(connectionString); + if (async) + { + await conn.OpenAsync(); + } + else + { + conn.Open(); + } + connections.Add(conn); + } + Assert.Equal(maxPoolSize, connections.Count); + } + catch + { + return false; + } + finally + { + // Clean up all connections + foreach (SqlConnection conn in connections) + { + conn?.Dispose(); + } + } + + return true; + } + + #endregion + + #region Pool Exhaustion Tests + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))] + [TestCategory("LongRunning")] // Takes around 13 seconds. + public async Task ConnectionPoolStress_Sync() + { + var test = new ConnectionPoolStressTest + { + MaxPoolSize = 100, + ConcurrentConnections = 10, + WaitForDelay = "00:00:00.100", + OperationsPerThread = 100, + }; + + test.SetConnectionString(DataTestUtility.TCPConnectionString); + + // Run the stress tests + if (!RunSingleStressTest(test.ConnectionPoolStress_MsData_Sync)) + { + // fail the test + Assert.Fail("ConnectionPoolStress_MsData_Sync failed"); + } + + if (!await TestConnectionPoolExhaustion(test.ConnectionString!, test.MaxPoolSize, false)) + { + // fail the test + Assert.Fail("ConnectionPoolStress_MsData_Sync failed"); + } + } + + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))] + [TestCategory("LongRunning")] // Takes around 11 seconds. + public async Task ConnectionPoolStress_Async() + { + var test = new ConnectionPoolStressTest + { + MaxPoolSize = 100, + ConcurrentConnections = 10, + WaitForDelay = "00:00:00.100", + OperationsPerThread = 100, + }; + + test.SetConnectionString(DataTestUtility.TCPConnectionString); + + // Test Microsoft.Data.SqlClient Async + if (!RunSingleStressTest(test.ConnectionPoolStress_MsData_Async)) + { + // fail the test + Assert.Fail("ConnectionPoolStress_MsData_Async failed"); + } + + // Test connection pool exhaustion (async) + if (!await TestConnectionPoolExhaustion(test.ConnectionString!, test.MaxPoolSize, true)) + { + // fail the test + Assert.Fail("ConnectionPoolStress_MsData_Async failed"); + } + } + + #endregion + } +}