Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2378,6 +2378,72 @@ private void Enlist(Transaction transaction)
// Only enlist if it's different...
EnlistNonNull(transaction);
}
else if (!LocalAppContextSwitches.UseLegacyTransactionScopeIsolationBehavior
&& _parser._fResetConnection)
{
Comment on lines +2381 to +2383
// 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);
Comment on lines +2426 to +2431

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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,18 @@ internal static class LocalAppContextSwitches
private const string UseLegacyFailoverAlternationOnLoginSqlErrorsString =
"Switch.Microsoft.Data.SqlClient.UseLegacyFailoverAlternationOnLoginSqlErrors";

/// <summary>
/// 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.
/// </summary>
private const string UseLegacyTransactionScopeIsolationBehaviorString =
"Switch.Microsoft.Data.SqlClient.UseLegacyTransactionScopeIsolationBehavior";

/// <summary>
/// The name of the app context switch that controls whether to preserve
/// legacy behavior where Timestamp/RowVersion fields return empty byte
Expand Down Expand Up @@ -201,6 +213,11 @@ private enum SwitchValue : byte
/// </summary>
private static SwitchValue s_useLegacyFailoverAlternationOnLoginSqlErrors = SwitchValue.None;

/// <summary>
/// The cached value of the UseLegacyTransactionScopeIsolationBehavior switch.
/// </summary>
private static SwitchValue s_useLegacyTransactionScopeIsolationBehavior = SwitchValue.None;

Comment on lines 213 to +220
/// <summary>
/// The cached value of the LegacyRowVersionNullBehavior switch.
/// </summary>
Expand Down Expand Up @@ -446,6 +463,25 @@ public static bool GlobalizationInvariantMode
defaultValue: false,
ref s_useLegacyFailoverAlternationOnLoginSqlErrors);

/// <summary>
/// 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.
/// </summary>
public static bool UseLegacyTransactionScopeIsolationBehavior =>
AcquireAndReturn(
UseLegacyTransactionScopeIsolationBehaviorString,
defaultValue: false,
ref s_useLegacyTransactionScopeIsolationBehavior);

/// <summary>
/// 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -100,6 +101,8 @@ public LocalAppContextSwitchesHelper()
GetSwitchValue("s_ignoreServerProvidedFailoverPartner");
_useLegacyFailoverAlternationOnLoginSqlErrorsOriginal =
GetSwitchValue("s_useLegacyFailoverAlternationOnLoginSqlErrors");
_useLegacyTransactionScopeIsolationBehaviorOriginal =
GetSwitchValue("s_useLegacyTransactionScopeIsolationBehavior");
_legacyRowVersionNullBehaviorOriginal =
GetSwitchValue("s_legacyRowVersionNullBehavior");
_legacyVarTimeZeroScaleBehaviourOriginal =
Expand Down Expand Up @@ -161,6 +164,9 @@ public void Dispose()
SetSwitchValue(
"s_useLegacyFailoverAlternationOnLoginSqlErrors",
_useLegacyFailoverAlternationOnLoginSqlErrorsOriginal);
SetSwitchValue(
"s_useLegacyTransactionScopeIsolationBehavior",
_useLegacyTransactionScopeIsolationBehaviorOriginal);
SetSwitchValue(
"s_legacyRowVersionNullBehavior",
_legacyRowVersionNullBehaviorOriginal);
Expand Down Expand Up @@ -261,6 +267,15 @@ public bool? UseLegacyFailoverAlternationOnLoginSqlErrors
set => SetSwitchValue("s_useLegacyFailoverAlternationOnLoginSqlErrors", value);
}

/// <summary>
/// Get or set the UseLegacyTransactionScopeIsolationBehavior switch value.
/// </summary>
public bool? UseLegacyTransactionScopeIsolationBehavior
{
get => GetSwitchValue("s_useLegacyTransactionScopeIsolationBehavior");
set => SetSwitchValue("s_useLegacyTransactionScopeIsolationBehavior", value);
}

/// <summary>
/// Get or set the LegacyRowVersionNullBehavior switch value.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@
<Compile Include="SQL\SqlStatisticsTest\SqlStatisticsTest.cs" />
<Compile Include="SQL\TransactionTest\DistributedTransactionTest.cs" />
<Compile Include="SQL\TransactionTest\TransactionEnlistmentTest.cs" />
<Compile Include="SQL\TransactionTest\TransactionScopeIsolationReassertTest.cs" />
<Compile Include="SQL\TransactionTest\TransactionTest.cs" />
<Compile Include="SQL\UdtTest\SqlServerTypesTest.cs" />
<Compile Include="SQL\UdtTest\UdtBulkCopyTest.cs" />
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
Comment on lines +34 to +60

[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<string> 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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Loading