From 7d48b0ed2b6327986f4db73696dbf3cd0bceb30b Mon Sep 17 00:00:00 2001 From: Priyanka Tiwari Date: Wed, 3 Jun 2026 13:17:38 +0530 Subject: [PATCH 1/2] Re-issue session isolation level on TransactionScope re-enlistment When a pooled connection is re-checked-out inside the same System.Transactions transaction, the existing Enlist() short-circuit skipped re-issuing SET TRANSACTION ISOLATION LEVEL. sp_reset_connection_keep_transaction resets the session isolation level to the database default on Azure SQL DB, silently downgrading subsequent commands in the scope (e.g. Serializable -> Read Committed Snapshot). Fix: on the re-attach path, re-issue SET TRANSACTION ISOLATION LEVEL matching the ambient transaction's isolation level. The statement is queued onto the same TDS batch as the pending reset, so there is no extra round trip. Back-compat: gated behind AppContext switch Switch.Microsoft.Data.SqlClient.UseLegacyTransactionScopeIsolationBehavior (default false). Validated against on-prem SQL Server (no behavior change) and Azure SQL DB (downgrade gone). Adds ManualTests gated on IsAzureServer. --- .../Connection/SqlConnectionInternal.cs | 66 ++++++++++++ .../Data/SqlClient/LocalAppContextSwitches.cs | 36 +++++++ ...icrosoft.Data.SqlClient.ManualTests.csproj | 1 + .../TransactionScopeIsolationReassertTest.cs | 101 ++++++++++++++++++ 4 files changed, 204 insertions(+) create mode 100644 src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/TransactionTest/TransactionScopeIsolationReassertTest.cs 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..cc01fec2a2 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 + && _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};", + timeout: 0, + 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/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..5b9b75fdea --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/TransactionTest/TransactionScopeIsolationReassertTest.cs @@ -0,0 +1,101 @@ +// 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 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(); + } + } + + 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; + } + } +} From 6c57f75f1f3ae436c77fe1a215daf5dd10fe1bd9 Mon Sep 17 00:00:00 2001 From: Priyanka Tiwari Date: Wed, 3 Jun 2026 14:00:58 +0530 Subject: [PATCH 2/2] Address PR review on re-assert isolation level fix - SqlConnectionInternal.Enlist: guard on _parser._fResetConnection (runtime reset-pending flag) instead of _fResetConnection (static config). - ReassertSessionIsolationLevel: use ConnectionOptions.ConnectTimeout for the in-driver SET batch (matches ChangeDatabase convention) instead of timeout: 0. - LocalAppContextSwitchesHelper / LocalAppContextSwitchesTest: wire UseLegacyTransactionScopeIsolationBehavior into the RAII helper and the defaults test. - ManualTests: add LegacySwitch_PreservesAzureDowngradeBehavior negative test asserting the back-compat switch fully restores the prior Azure downgrade behavior. --- .../Connection/SqlConnectionInternal.cs | 4 +- .../Common/LocalAppContextSwitchesHelper.cs | 15 ++++++ .../TransactionScopeIsolationReassertTest.cs | 46 +++++++++++++++++++ .../SqlClient/LocalAppContextSwitchesTest.cs | 2 + 4 files changed, 65 insertions(+), 2 deletions(-) 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 cc01fec2a2..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 @@ -2379,7 +2379,7 @@ private void Enlist(Transaction transaction) EnlistNonNull(transaction); } else if (!LocalAppContextSwitches.UseLegacyTransactionScopeIsolationBehavior - && _fResetConnection) + && _parser._fResetConnection) { // Same System.Transactions transaction being re-attached to the same // pooled physical connection (transacted-pool re-checkout inside an @@ -2425,7 +2425,7 @@ private void ReassertSessionIsolationLevel(System.Transactions.IsolationLevel sy { Task executeTask = _parser.TdsExecuteSQLBatch( $"SET TRANSACTION ISOLATION LEVEL {isoSql};", - timeout: 0, + ConnectionOptions.ConnectTimeout, notificationRequest: null, _parser._physicalStateObj, sync: true); 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/SQL/TransactionTest/TransactionScopeIsolationReassertTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/TransactionTest/TransactionScopeIsolationReassertTest.cs index 5b9b75fdea..3a1d9e1a19 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/TransactionTest/TransactionScopeIsolationReassertTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/TransactionTest/TransactionScopeIsolationReassertTest.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using System.Transactions; +using Microsoft.Data.SqlClient.Tests.Common; using Xunit; namespace Microsoft.Data.SqlClient.ManualTesting.Tests @@ -86,6 +87,51 @@ public static async Task TransactionScope_ReadUncommittedHonoredAcrossPoolReuse( } } + // 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); 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);