Re-issue session isolation level on TransactionScope re-enlistment - #4335
priyankatiwari08 wants to merge 17 commits into
Conversation
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.
There was a problem hiding this comment.
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-issueSET 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. |
- 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.
…aded-dollop # Conflicts: # src/Microsoft.Data.SqlClient/tests/ManualTests/Microsoft.Data.SqlClient.ManualTests.csproj # src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs
- ReassertSessionIsolationLevel: do not emit SET TRANSACTION ISOLATION LEVEL SNAPSHOT. Switching to SNAPSHOT while a transaction is active causes SQL Server to fail and roll back that transaction, and this path always runs with the preserved transaction still open. The transaction was already begun under snapshot isolation via the TM request, so there is nothing to re-assert. - Correct the Enlist comment: the queued reset piggybacks the SET batch, but the SET batch itself is an extra round trip on re-checkout. - LocalAppContextSwitchesHelper: use GetSwitchPropertyValue for UseLegacyTransactionScopeIsolationBehavior to match the accessor pattern main moved to, so the defaults test reads the resolved value instead of the uncached field. - Document the new switch in features.instructions.md. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs:2412
- The PR description states the isolation re-assert is queued onto the same batch as the pending reset with no extra round trip, but this implementation runs a synchronous
TdsExecuteSQLBatch+RunduringOpen/Enlist, which introduces an additional server round trip on the re-attach path. Please reconcile the PR description with the actual behavior, or adjust the implementation to truly piggyback without an extraOpen-time execute.
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 queued reset piggybacks this batch's TDS
// header, so the reset itself costs nothing extra, but the SET batch
// is an additional round trip on re-checkout.
ReassertSessionIsolationLevel(transaction.IsolationLevel);
src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/TransactionTest/TransactionScopeIsolationReassertTest.cs:19
- ManualTests in this folder are partitioned with
[Trait("Set", "3")](see TransactionTest.cs / TransactionEnlistmentTest.cs / DistributedTransactionTest.cs). This new test class is missing the trait, which can cause it to run outside the intended ManualTests set partitioning.
public static class TransactionScopeIsolationReassertTest
{
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4335 +/- ##
==========================================
- Coverage 71.96% 64.55% -7.41%
==========================================
Files 291 285 -6
Lines 45110 68100 +22990
==========================================
+ Hits 32462 43965 +11503
- Misses 12648 24135 +11487
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Explains why the pool-return isolation-level scrub (dotnet#96) and the TransactionScope re-enlistment re-assert (dotnet#146) are opposite failures of the same sp_reset_connection inconsistency, and why neither fix subsumes the other. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6a5c2db9-5c3a-4cbb-9ae5-d120b6aa1988
|
Added a design note at Short version: both bugs come from the same fact —
Neither subsumes the other: this PR only fires on the The two PRs do overlap textually (same file, same switches helper, same test folder). Suggested sequencing is #4330 first, then rebase this one on top and settle the open perf question (unconditional SET vs. Azure-gated vs. deferring the SET to prefix the user's next batch). |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/TransactionTest/TransactionScopeIsolationReassertTest.cs:18
- ManualTests are partitioned by the xUnit
Settrait (see build.proj TestSetFilter). This new test class doesn’t declare aSet, so it may be skipped when CI runs specific manual test sets (e.g., Set=1/2/3). Add the same[Trait("Set", "3")]used by other transaction manual tests so these new tests reliably execute in the manual test matrix.
public static class TransactionScopeIsolationReassertTest
TransactionScope_SecondConnectionInSameScopeKeepsIsolationLevel failed on every Azure SQL Set-3 CI leg while passing on all 24 on-prem legs. The failure is not a regression in this change. The test asserts that the second Open inside a live TransactionScope still observes Serializable. On Azure SQL DB that assertion cannot hold, because Azure clears the session isolation level inside sp_reset_connection_keep_transaction when the pooled connection is vended back into the same scope. That is issue dotnet#146, which is out of scope for this PR and is addressed separately by PR dotnet#4335. The enlistment gate this test is meant to guard is verified by the on-prem legs, where the reset is correctly skipped while the connection is still enlisted. Add nameof(DataTestUtility.IsNotAzureServer) so the test runs only where its premise holds, matching the existing precedent on LegacySwitch_PreservesOldLeakBehavior in the same class. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e54f2587-2955-4c93-9896-a35e73feb6e5
|
I want to add a comprehensive test suite that covers the entire possible public API surface. We should test every possible interleaving of calls and assert the expected outcome. That will help us ensure we don't regress any other paths during this fix. I'll post a PR for that suite when it's ready and tag it here. |
…n tests Addresses review feedback on dotnet#4335: - Comments in SqlConnectionInternal referred to sp_reset_connection when the transacted-pool re-checkout path actually issues sp_reset_connection_keep_transaction. Corrected all three sites. - Expand TransactionScopeIsolationReassertTest with the coverage requested in review: mid-scope session-level overrides, Snapshot scopes, MARS on/off and connection pool V1/V2, for both the sync and async paths. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0a3f98bb-aab0-4cdf-a54b-33578f16e15a
…ope-isolation-reassert
- Issue the session-override SET as its own command: under MARS a SET inside a multi-statement batch applies only to that batch's execution environment, so the override never reached the session. - Give each test method its own pool via a distinct ApplicationName tag. - Replace the invalid Snapshot session-override theories with Snapshot re-checkout coverage; SQL Server defers a SET issued inside an open snapshot transaction, so the session cannot be moved off Snapshot mid-scope. - Add the async session-override theory. 24/24 pass on SQL Server 2022 across MARS on/off and pool V1/V2. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0a3f98bb-aab0-4cdf-a54b-33578f16e15a
bd6df51
Synapse / Fabric DW validation — decision#4330 needed a live Synapse + Fabric DW run because its reset forces
So there is no code path this PR adds that a Synapse or Fabric DW endpoint can reach, and no endpoint guard is warranted — adding one would suppress nothing. This supersedes my earlier note deferring the Synapse run. Automated endpoint coverage stays tracked in #4580. Test suite
This ports the meaningful coverage from @mdaigle's draft suite in #4636. Two server behaviours shaped the final shape of these tests and are worth recording:
Each theory uses a distinct |
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Unresolved critical and moderate review findings block approval.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
Open (3)
Resolved since last review (1)
- Add XML <summary>/<param>/<returns> to TransactionScopeIsolationReassertTest per .github/instructions/testing.instructions.md. - EnlistTransaction now passes ConnectTimeout rather than CommandTimeout, and the comments no longer claim a command-timeout path; all enlistment I/O in this file is bounded by the connect timeout. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0a3f98bb-aab0-4cdf-a54b-33578f16e15a
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Update the pool-version test scoping; timeout documentation and Unicode-style nits also remain.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
Open (2)
| [InlineData(true, true)] | ||
| public static void TransactionScope_ReassertsLevelAfterSessionOverride_Sync(bool mars, bool usePoolV2) | ||
| { | ||
| using LocalAppContextSwitchesHelper switches = new() { UseConnectionPoolV2 = usePoolV2 }; |
| /// every back end (notably Azure SQL DB), which silently downgraded the scope's level from | ||
| /// the second open onwards (issue #146). | ||
| /// | ||
| /// The reset itself is free — it rides as a bit in the next packet's TDS header — but the |



Summary
Fixes #146 by preserving the ambient
TransactionScopeisolation level when an enlisted physical connection is reused from the transacted pool.On Azure SQL DB,
sp_reset_connection_keep_transactioncan reset the session isolation level. Re-enlistment in the same transaction previously short-circuited without restoring it, causing later opens in the scope to run at the database default.Changes
ReadCommitted, where re-assertion is unnecessary.Snapshotre-checkout, each across sync/async, MARS on/off and pool V1/V2.The fix is unconditional; the previous behavior is a correctness bug and has no compatibility switch.
Performance
Non-
ReadCommittedre-checkouts add one round trip. The benchmark measured approximately 0.15 ms per re-assertion on localhost; network latency determines the real-world cost.Checklist