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);