Skip to content

Re-issue session isolation level on TransactionScope re-enlistment (fixes #146)#4335

Draft
priyankatiwari08 wants to merge 2 commits into
dotnet:mainfrom
priyankatiwari08:feature/transactionscope-isolation-reassert
Draft

Re-issue session isolation level on TransactionScope re-enlistment (fixes #146)#4335
priyankatiwari08 wants to merge 2 commits into
dotnet:mainfrom
priyankatiwari08:feature/transactionscope-isolation-reassert

Conversation

@priyankatiwari08
Copy link
Copy Markdown
Contributor

Summary

Fix #146TransactionScope ambient isolation level is silently downgraded after the first pooled connection re-checkout on Azure SQL DB.

Repro

using var scope = new TransactionScope(
    TransactionScopeOption.Required,
    new TransactionOptions { IsolationLevel = IsolationLevel.Serializable },
    TransactionScopeAsyncFlowOption.Enabled);

for (int i = 0; i < 3; i++)
{
    using var c = new SqlConnection(azureConnStringWithMaxPoolSize1);
    await c.OpenAsync();
    // DBCC USEROPTIONS:
    //   i==0 -> serializable
    //   i>=1 -> read committed snapshot   <-- bug
}

On on-prem SQL Server the level survives and all three opens report serializable. On Azure SQL DB the second and subsequent opens report the database default isolation (e.g. read committed snapshot).

Root cause

  1. First Open() inside the scope enlists the connection and sends SET TRANSACTION ISOLATION LEVEL <ambient>;.
  2. On Close() the physical connection is returned to the transacted pool still enlisted in the same Transaction.
  3. Second Open() reuses the same physical connection. SqlInternalConnectionTds.Enlist(Transaction) sees transaction.Equals(EnlistedTransaction) and short-circuits — no SET is sent.
  4. A pending sp_reset_connection_keep_transaction piggybacks on the next batch. On Azure SQL DB this reset clears the session isolation level to the database default; on on-prem SQL Server it does not, which is why the bug is Azure-only in practice.

Fix

On the short-circuit path in Enlist, when reset is pending and the new behavior switch is enabled, re-issue:

SET TRANSACTION ISOLATION LEVEL <ambient>;

mapped from Transaction.IsolationLevel. The statement is queued onto the same TDS batch as the reset, so there is no extra round trip.

Gated behind a new AppContext switch for back-compat:

  • Switch.Microsoft.Data.SqlClient.UseLegacyTransactionScopeIsolationBehavior (default false).

Same back-compat pattern used by the related companion PR #4330 (UseLegacyIsolationLevelBehavior).

Files changed

  • src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs — new switch.
  • src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs — new re-attach branch + ReassertSessionIsolationLevel helper.
  • src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/TransactionTest/TransactionScopeIsolationReassertTest.cs — new ManualTests, gated on IsAzureServer.
  • src/Microsoft.Data.SqlClient/tests/ManualTests/Microsoft.Data.SqlClient.ManualTests.csproj — wire-up.

Validation

End-to-end repro against both back ends with Pooling=true; Max Pool Size=1, opening 3 connections per scope.

Scenario Conn 1 Conn 2 Conn 3
On-prem SQL Server, default serializable serializable serializable
Azure SQL DB, default (fix) serializable serializable serializable
Azure SQL DB, legacy switch on serializable read committed snapshot read committed snapshot

Build clean on net462, net8.0, net9.0 (0 warnings, 0 errors).

Notes

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.
Copilot AI review requested due to automatic review settings June 3, 2026 07:48
@priyankatiwari08 priyankatiwari08 requested a review from a team as a code owner June 3, 2026 07:48
@github-project-automation github-project-automation Bot moved this to To triage in SqlClient Board Jun 3, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes an Azure SQL DB-specific TransactionScope pooling regression where session isolation level can revert to the database default after transacted-pool re-checkout by re-asserting the ambient isolation level during re-enlistment.

Changes:

  • Added a new AppContext switch (Switch.Microsoft.Data.SqlClient.UseLegacyTransactionScopeIsolationBehavior) to gate the new re-assert behavior.
  • Updated SqlInternalConnectionTds.Enlist(Transaction) to re-issue SET TRANSACTION ISOLATION LEVEL ... on the “same transaction” short-circuit path (intended to piggyback on the pending reset).
  • Added new Azure-gated ManualTests and wired them into the ManualTests project.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs Adds re-attach logic to re-assert session isolation level when re-enlisting into the same ambient transaction.
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs Introduces a new AppContext switch to enable legacy behavior.
src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/TransactionTest/TransactionScopeIsolationReassertTest.cs Adds ManualTests validating isolation level stability across pooled re-opens inside a TransactionScope (Azure-only).
src/Microsoft.Data.SqlClient/tests/ManualTests/Microsoft.Data.SqlClient.ManualTests.csproj Includes the new ManualTests source file in the build.

Comment on lines +2426 to +2431
Task executeTask = _parser.TdsExecuteSQLBatch(
$"SET TRANSACTION ISOLATION LEVEL {isoSql};",
timeout: 0,
notificationRequest: null,
_parser._physicalStateObj,
sync: true);
Comment on lines 213 to +220
/// </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 +33 to +59
[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 +2381 to +2383
else if (!LocalAppContextSwitches.UseLegacyTransactionScopeIsolationBehavior
&& _fResetConnection)
{
@priyankatiwari08 priyankatiwari08 added this to the 7.0.2 milestone Jun 3, 2026
- 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: To triage

Development

Successfully merging this pull request may close these issues.

Wrong isolation level with Sql Azure and TransactionScope

2 participants