diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs index 295286b1e5..37b404e46f 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs @@ -2378,6 +2378,72 @@ private void Enlist(Transaction transaction) // Only enlist if it's different... EnlistNonNull(transaction); } + else if (!LocalAppContextSwitches.UseLegacyTransactionScopeIsolationBehavior + && _parser._fResetConnection) + { + // Same System.Transactions transaction being re-attached to the same + // pooled physical connection (transacted-pool re-checkout inside an + // open TransactionScope). The queued sp_reset_connection_keep_transaction + // does not preserve the SQL Server session isolation level on every + // server (notably Azure SQL DB), so without re-asserting the level the + // second and later opens inside the scope would silently run at the + // database default. The SET batch piggybacks the queued reset on its + // TDS header, so no extra round trip is added. + ReassertSessionIsolationLevel(transaction.IsolationLevel); + } + } + + // Re-issues SET TRANSACTION ISOLATION LEVEL on the physical state object so + // the next batch in this pooled connection observes the System.Transactions + // ambient isolation level even after sp_reset_connection resets the session. + private void ReassertSessionIsolationLevel(System.Transactions.IsolationLevel sysIso) + { + string isoSql; + switch (sysIso) + { + case System.Transactions.IsolationLevel.ReadUncommitted: + isoSql = "READ UNCOMMITTED"; + break; + case System.Transactions.IsolationLevel.ReadCommitted: + isoSql = "READ COMMITTED"; + break; + case System.Transactions.IsolationLevel.RepeatableRead: + isoSql = "REPEATABLE READ"; + break; + case System.Transactions.IsolationLevel.Serializable: + isoSql = "SERIALIZABLE"; + break; + case System.Transactions.IsolationLevel.Snapshot: + isoSql = "SNAPSHOT"; + break; + default: + // Unspecified / Chaos: nothing meaningful to assert. + return; + } + + try + { + Task executeTask = _parser.TdsExecuteSQLBatch( + $"SET TRANSACTION ISOLATION LEVEL {isoSql};", + ConnectionOptions.ConnectTimeout, + notificationRequest: null, + _parser._physicalStateObj, + sync: true); + + Debug.Assert(executeTask == null, "Shouldn't get a task when doing sync writes"); + + _parser.Run( + RunBehavior.UntilDone, + cmdHandler: null, + dataStream: null, + bulkCopyHandler: null, + _parser._physicalStateObj); + } + catch (Exception e) when (ADP.IsCatchableExceptionType(e)) + { + DoomThisConnection(); + throw; + } } private void EnlistNonNull(Transaction transaction) diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs index df762ed7ae..ce5a368ad4 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs @@ -65,6 +65,18 @@ internal static class LocalAppContextSwitches private const string UseLegacyFailoverAlternationOnLoginSqlErrorsString = "Switch.Microsoft.Data.SqlClient.UseLegacyFailoverAlternationOnLoginSqlErrors"; + /// + /// The name of the app context switch that controls whether pooled + /// connections re-assert the System.Transactions ambient isolation level + /// when the same physical connection is handed back to an open + /// TransactionScope. On servers (e.g. Azure SQL DB) where + /// sp_reset_connection_keep_transaction resets the session isolation + /// level, skipping the re-assert causes the second and later + /// SqlConnection.Open() inside the scope to run at the database default. + /// + private const string UseLegacyTransactionScopeIsolationBehaviorString = + "Switch.Microsoft.Data.SqlClient.UseLegacyTransactionScopeIsolationBehavior"; + /// /// The name of the app context switch that controls whether to preserve /// legacy behavior where Timestamp/RowVersion fields return empty byte @@ -201,6 +213,11 @@ private enum SwitchValue : byte /// private static SwitchValue s_useLegacyFailoverAlternationOnLoginSqlErrors = SwitchValue.None; + /// + /// The cached value of the UseLegacyTransactionScopeIsolationBehavior switch. + /// + private static SwitchValue s_useLegacyTransactionScopeIsolationBehavior = SwitchValue.None; + /// /// The cached value of the LegacyRowVersionNullBehavior switch. /// @@ -446,6 +463,25 @@ public static bool GlobalizationInvariantMode defaultValue: false, ref s_useLegacyFailoverAlternationOnLoginSqlErrors); + /// + /// When set to true, pooled connections preserve the legacy behavior where + /// the ambient System.Transactions isolation level is not re-asserted on + /// the second and later SqlConnection.Open() inside the same + /// TransactionScope. As a result, on servers that reset the session + /// isolation level during sp_reset_connection (e.g. Azure SQL DB) those + /// later opens silently run at the database default rather than at the + /// scope's isolation level. + /// + /// The default value of this switch is false, meaning the driver will + /// re-issue SET TRANSACTION ISOLATION LEVEL on the re-attach so that the + /// scope's isolation level is honored across every connection inside it. + /// + public static bool UseLegacyTransactionScopeIsolationBehavior => + AcquireAndReturn( + UseLegacyTransactionScopeIsolationBehaviorString, + defaultValue: false, + ref s_useLegacyTransactionScopeIsolationBehavior); + /// /// In System.Data.SqlClient and Microsoft.Data.SqlClient prior to 3.0.0 a /// field with type Timestamp/RowVersion would return an empty byte array. diff --git a/src/Microsoft.Data.SqlClient/tests/Common/LocalAppContextSwitchesHelper.cs b/src/Microsoft.Data.SqlClient/tests/Common/LocalAppContextSwitchesHelper.cs index 430d6645a8..8eeb33d843 100644 --- a/src/Microsoft.Data.SqlClient/tests/Common/LocalAppContextSwitchesHelper.cs +++ b/src/Microsoft.Data.SqlClient/tests/Common/LocalAppContextSwitchesHelper.cs @@ -46,6 +46,7 @@ public sealed class LocalAppContextSwitchesHelper : IDisposable #endif private readonly bool? _ignoreServerProvidedFailoverPartnerOriginal; private readonly bool? _useLegacyFailoverAlternationOnLoginSqlErrorsOriginal; + private readonly bool? _useLegacyTransactionScopeIsolationBehaviorOriginal; private readonly bool? _legacyRowVersionNullBehaviorOriginal; private readonly bool? _legacyVarTimeZeroScaleBehaviourOriginal; private readonly bool? _makeReadAsyncBlockingOriginal; @@ -100,6 +101,8 @@ public LocalAppContextSwitchesHelper() GetSwitchValue("s_ignoreServerProvidedFailoverPartner"); _useLegacyFailoverAlternationOnLoginSqlErrorsOriginal = GetSwitchValue("s_useLegacyFailoverAlternationOnLoginSqlErrors"); + _useLegacyTransactionScopeIsolationBehaviorOriginal = + GetSwitchValue("s_useLegacyTransactionScopeIsolationBehavior"); _legacyRowVersionNullBehaviorOriginal = GetSwitchValue("s_legacyRowVersionNullBehavior"); _legacyVarTimeZeroScaleBehaviourOriginal = @@ -161,6 +164,9 @@ public void Dispose() SetSwitchValue( "s_useLegacyFailoverAlternationOnLoginSqlErrors", _useLegacyFailoverAlternationOnLoginSqlErrorsOriginal); + SetSwitchValue( + "s_useLegacyTransactionScopeIsolationBehavior", + _useLegacyTransactionScopeIsolationBehaviorOriginal); SetSwitchValue( "s_legacyRowVersionNullBehavior", _legacyRowVersionNullBehaviorOriginal); @@ -261,6 +267,15 @@ public bool? UseLegacyFailoverAlternationOnLoginSqlErrors set => SetSwitchValue("s_useLegacyFailoverAlternationOnLoginSqlErrors", value); } + /// + /// Get or set the UseLegacyTransactionScopeIsolationBehavior switch value. + /// + public bool? UseLegacyTransactionScopeIsolationBehavior + { + get => GetSwitchValue("s_useLegacyTransactionScopeIsolationBehavior"); + set => SetSwitchValue("s_useLegacyTransactionScopeIsolationBehavior", value); + } + /// /// Get or set the LegacyRowVersionNullBehavior switch value. /// diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/Microsoft.Data.SqlClient.ManualTests.csproj b/src/Microsoft.Data.SqlClient/tests/ManualTests/Microsoft.Data.SqlClient.ManualTests.csproj index 1145fa4790..be3feaffa2 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/Microsoft.Data.SqlClient.ManualTests.csproj +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/Microsoft.Data.SqlClient.ManualTests.csproj @@ -234,6 +234,7 @@ + diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/TransactionTest/TransactionScopeIsolationReassertTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/TransactionTest/TransactionScopeIsolationReassertTest.cs new file mode 100644 index 0000000000..3a1d9e1a19 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/TransactionTest/TransactionScopeIsolationReassertTest.cs @@ -0,0 +1,147 @@ +// 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.Threading.Tasks; +using System.Transactions; +using Microsoft.Data.SqlClient.Tests.Common; +using Xunit; + +namespace Microsoft.Data.SqlClient.ManualTesting.Tests +{ + // Verifies that every connection opened inside a TransactionScope observes + // the scope's isolation level, even after a pooled physical connection is + // re-checked-out from the transacted pool. The driver must re-issue + // SET TRANSACTION ISOLATION LEVEL on the re-attach because + // sp_reset_connection does not preserve the session isolation level on + // every server (notably Azure SQL DB). + public static class TransactionScopeIsolationReassertTest + { + private const string GetIsoSql = @" +SELECT CASE transaction_isolation_level + WHEN 0 THEN 'Unspecified' + WHEN 1 THEN 'ReadUncommitted' + WHEN 2 THEN 'ReadCommitted' + WHEN 3 THEN 'RepeatableRead' + WHEN 4 THEN 'Serializable' + WHEN 5 THEN 'Snapshot' +END +FROM sys.dm_exec_sessions WHERE session_id = @@SPID;"; + + // Only meaningful on Azure SQL DB, where sp_reset_connection resets the + // session isolation level. On on-prem the symptom does not surface + // because the level survives the reset. + [ConditionalFact( + typeof(DataTestUtility), + nameof(DataTestUtility.AreConnStringsSetup), + nameof(DataTestUtility.IsAzureServer))] + public static async Task TransactionScope_SerializableHonoredAcrossPoolReuse() + { + string cs = new SqlConnectionStringBuilder(DataTestUtility.TCPConnectionString) + { + Pooling = true, + MaxPoolSize = 1, + ApplicationName = nameof(TransactionScopeIsolationReassertTest) + }.ConnectionString; + + using (var scope = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.Serializable }, + TransactionScopeAsyncFlowOption.Enabled)) + { + for (int i = 0; i < 3; i++) + { + string level = await GetSessionIsolationLevelAsync(cs); + Assert.Equal("Serializable", level); + } + + scope.Complete(); + } + } + + [ConditionalFact( + typeof(DataTestUtility), + nameof(DataTestUtility.AreConnStringsSetup), + nameof(DataTestUtility.IsAzureServer))] + public static async Task TransactionScope_ReadUncommittedHonoredAcrossPoolReuse() + { + string cs = new SqlConnectionStringBuilder(DataTestUtility.TCPConnectionString) + { + Pooling = true, + MaxPoolSize = 1, + ApplicationName = nameof(TransactionScopeIsolationReassertTest) + }.ConnectionString; + + using (var scope = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadUncommitted }, + TransactionScopeAsyncFlowOption.Enabled)) + { + for (int i = 0; i < 3; i++) + { + string level = await GetSessionIsolationLevelAsync(cs); + Assert.Equal("ReadUncommitted", level); + } + + scope.Complete(); + } + } + + // Negative test: with the legacy switch enabled, the second and later + // opens inside the scope should observe the database default isolation + // (Azure SQL DB resets the level on sp_reset_connection). Proves the + // back-compat switch fully restores the previous behavior. + [ConditionalFact( + typeof(DataTestUtility), + nameof(DataTestUtility.AreConnStringsSetup), + nameof(DataTestUtility.IsAzureServer))] + public static async Task LegacySwitch_PreservesAzureDowngradeBehavior() + { + using LocalAppContextSwitchesHelper switchesHelper = new(); + switchesHelper.UseLegacyTransactionScopeIsolationBehavior = true; + + string cs = new SqlConnectionStringBuilder(DataTestUtility.TCPConnectionString) + { + Pooling = true, + MaxPoolSize = 1, + ApplicationName = nameof(TransactionScopeIsolationReassertTest) + "-Legacy" + }.ConnectionString; + + try + { + using var scope = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.Serializable }, + TransactionScopeAsyncFlowOption.Enabled); + + // First open inside the scope sets the level via TM Begin. + string first = await GetSessionIsolationLevelAsync(cs); + Assert.Equal("Serializable", first); + + // Second open re-checks-out the same pooled physical connection. + // With the legacy switch on, no SET is re-issued, and Azure's + // sp_reset_connection drops the session level to the DB default. + string second = await GetSessionIsolationLevelAsync(cs); + Assert.NotEqual("Serializable", second); + + scope.Complete(); + } + finally + { + SqlConnection.ClearAllPools(); + } + } + + private static async Task GetSessionIsolationLevelAsync(string cs) + { + using SqlConnection conn = new(cs); + await conn.OpenAsync(); + + using SqlCommand cmd = conn.CreateCommand(); + cmd.CommandText = GetIsoSql; + + object result = await cmd.ExecuteScalarAsync(); + return result?.ToString() ?? string.Empty; + } + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs index c5c6f7ec73..a3a2631dbe 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs @@ -30,6 +30,7 @@ public void TestDefaultAppContextSwitchValues() switchesHelper.EnableMultiSubnetFailoverByDefault = null; switchesHelper.IgnoreServerProvidedFailoverPartner = null; switchesHelper.UseLegacyFailoverAlternationOnLoginSqlErrors = null; + switchesHelper.UseLegacyTransactionScopeIsolationBehavior = null; switchesHelper.LegacyRowVersionNullBehavior = null; switchesHelper.LegacyVarTimeZeroScaleBehaviour = null; switchesHelper.MakeReadAsyncBlocking = null; @@ -61,6 +62,7 @@ public void TestDefaultAppContextSwitchValues() Assert.False(LocalAppContextSwitches.TruncateScaledDecimal); Assert.False(LocalAppContextSwitches.IgnoreServerProvidedFailoverPartner); Assert.False(LocalAppContextSwitches.UseLegacyFailoverAlternationOnLoginSqlErrors); + Assert.False(LocalAppContextSwitches.UseLegacyTransactionScopeIsolationBehavior); Assert.False(LocalAppContextSwitches.EnableMultiSubnetFailoverByDefault); #if NET Assert.False(LocalAppContextSwitches.GlobalizationInvariantMode);