Fix | Preserve delegated transactions when resetting a pooled connection - #4557
Conversation
There was a problem hiding this comment.
Pull request overview
Fixes a regression in pooled-connection reset behavior where a connection that is the root of a delegated TransactionScope transaction could have its server-side transaction unintentionally cleared during reset, leading to a later rollback dooming the physical connection and surfacing as “connection has been broken”.
Changes:
- Widened the
PrepareResetConnectionpreserve-transaction condition to include delegated transaction roots (IsTransactionRoot) in addition to enlisted transactions (EnlistedTransaction != null). - Expanded inline comments in
ResetConnection()to document the two mutually exclusive transaction-participation cases and link the regression root cause (#4001).
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (4)
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs:3950
- This removes the pre-#3019
Is2008OrNewersafeguard, but SQL Server 2005 is still an accepted server version (tests/UnitTests/SimulatedServerTests/ConnectionTests.cs:768andTdsEnums.SQL2005_VERSION). The old helper intentionally did not request transaction-preserving reset for pre-2008 servers; this condition now sends that preserve flag for a pooled delegated root on SQL Server 2005, where the reset protocol does not support it. Retain the server-version guard for the root case while preserving enlisted participants on all supported servers.
_parser.PrepareResetConnection(
Pool is not null &&
(IsTransactionRoot || EnlistedTransaction is not null));
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs:3950
- Preserving the TDS transaction here is unsafe unless the delegated root is also kept out of general circulation. In the exact state this fix targets (
IsTransactionRoot == true,EnlistedTransaction == null), both pool implementations use onlyEnlistedTransactionto choose the transacted pool, so this connection is returned to the general idle pool. A later caller with no ambient transaction can then reuse the still-live server transaction becauseActivate(null)deliberately leaves an active delegated root alone. Please update the pool return/stasis path to hold active delegated roots until transaction completion, rather than changing only the reset flag.
_parser.PrepareResetConnection(
Pool is not null &&
(IsTransactionRoot || EnlistedTransaction is not null));
doc/design-notes/4001-delegated-transaction-reset.md:31
- The note says a delegated root always has
EnlistedTransaction == null, butSqlInternalConnectionTds.EnlistNonNullassignsEnlistedTransaction = transactioneven when promotable delegation succeeds.nullis specific to the detached half-state involved in #4001, not to every delegated root; leaving this as an invariant obscures why the pool normally parks roots and why this failure window is exceptional. Please scope the statement to the post-detachment state.
- `IsTransactionRoot` → `true`
- `EnlistedTransaction` → **`null`**
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs:3950
- This fixes a serious pool-corruption regression, but no regression test is included for the new delegated-root branch. The existing transaction suite passes with both the pre-#3019 and #3019 conditions, so it cannot detect either side of this union. Please add a deterministic test (or a focused harness) that fails with both buggy conditions and verifies rollback leaves the pooled connection usable; otherwise this behavior can regress again without CI detecting it.
This issue also appears in the following locations of the same file:
- line 3948
- line 3948
_parser.PrepareResetConnection(
Pool is not null &&
(IsTransactionRoot || EnlistedTransaction is not null));
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (4)
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs:3950
- Please retain the pre-#3019 server-version guard on the delegated-root branch. The old
IsNonPoolableTransactionRootlogic requiredIs2008OrNewer && Pool != nullfor a root to request transaction-preserving reset; this union removes that guard, so a pooled delegated root connected to supported SQL Server 2005 (Is2008OrNewer == false) now sendsST_RESET_CONNECTION_PRESERVE_TRANSACTION. Keep the guard only forIsTransactionRootwhile leaving the enlisted-participant branch unchanged.
_parser.PrepareResetConnection(
Pool is not null &&
(IsTransactionRoot || EnlistedTransaction is not null));
doc/design-notes/4001-delegated-transaction-reset.md:48
- This note states that delegated roots always have a null
EnlistedTransaction, butSqlConnectionInternal.EnlistNonNullassigns that property after a successful delegated enlistment; the same note also lists thetrue/setcombination as reachable. The null value is the edge state relevant to #4001, not a general invariant, so please rewrite this section to distinguish the cases by their intended role and explain why both indicators are checked.
- `IsTransactionRoot` → `true`
- `EnlistedTransaction` → **`null`**
The `null` is not an oversight. There is no external transaction object to point at,
because the transaction *is here*.
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs:3943
- The normal
EnlistNonNullpath assignsEnlistedTransactionafter both successful delegation and participant enlistment, so a delegated root is not intrinsically defined by a null value here. The null state can occur during the cleanup/detach transition described by #4001, but this comment's unconditional wording conflicts with the implementation and can mislead future changes; describe it as an edge state that requires checkingIsTransactionRootrather than as an invariant.
// - This connection is the root of a delegated transaction. The transaction has been
// delegated to (and lives on) this connection, so it has no EnlistedTransaction.
// - This connection merely enlisted in someone else's transaction, in which case
// EnlistedTransaction is set but the connection is not the root.
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs:3950
- The changed predicate has no regression test for the delegated-root close/reset/rollback state. The existing transaction suite passes with both the pre-#3019 and #3019 predicates, as documented in this PR, so CI will not detect a reintroduction of #4001. Add a deterministic regression test for the
IsTransactionRoot == truecase (and retain coverage for the enlisted-participant case), or extract the decision into a unit-testable helper and cover both combinations.
_parser.PrepareResetConnection(
Pool is not null &&
(IsTransactionRoot || EnlistedTransaction is not null));
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4557 +/- ##
==========================================
- Coverage 64.80% 63.47% -1.34%
==========================================
Files 288 283 -5
Lines 44448 67650 +23202
==========================================
+ Hits 28806 42940 +14134
- Misses 15642 24710 +9068
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:
|
paulmedynski
left a comment
There was a problem hiding this comment.
Where is the new test (or tests) that fails before this change, and passes afterwards?
mdaigle
left a comment
There was a problem hiding this comment.
Would also like to see a test added. We should be able to trigger a distributed transaction by concurrently holding two connections to the same server in the same transaction scope. The first to open will be the delegate, the other will be enlisted. Return the delegate first, then check it out again and we should be able to verify the state of that connection and the state of the overall transaction.
|
@priyankatiwari08 This pull request has been marked as Author attention needed. When you have addressed the reviewer feedback and are ready for another review, please post a comment with |
Fixes dotnet#4001 A connection can be tied to a transaction in one of two mutually exclusive ways on this code path: - It is the *root* of a delegated transaction. The transaction has been delegated down to this connection, so IsTransactionRoot is true and EnlistedTransaction is null. - It merely *enlisted* in a transaction owned elsewhere, so EnlistedTransaction is set and IsTransactionRoot is false. Before dotnet#3019, ResetConnection() only preserved the transaction for the delegated-root case, which missed the enlisted case (dotnet#2970). PR dotnet#3019 replaced that check with `EnlistedTransaction is not null` rather than adding to it, which fixed dotnet#2970 but silently dropped the delegated-root case. The result is that a connection returned to the pool while it is still the root of a live delegated transaction has its server-side transaction reset out from under System.Transactions. When the TransactionScope later rolls back, SqlDelegatedTransaction.Rollback fails and dooms the connection. With a small pool the same doomed physical connection is handed straight back out, producing "The requested operation cannot be completed because the connection has been broken." Preserve the transaction when either condition holds. This is a strict superset of both the pre-dotnet#3019 and post-dotnet#3019 behavior, so it cannot regress either issue. Verified against the reporter's repro on both the WaitHandle and V2 (channel) connection pools, and against the full manual TransactionTest suite (9/9 passing). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: cadc8f9e-e4ac-4074-92ef-88e90df96091
Records the delegated-root vs enlisted-participant distinction that this bug turns on, why dotnet#3019 swapped one case for the other rather than covering both, and why the union condition cannot reintroduce dotnet#2970. Also records the result of mutation testing the manual TransactionTest suite: the suite passes against the pre-dotnet#3019 condition (which carries dotnet#2970) and against the dotnet#3019 condition (which carries dotnet#4001), so it does not currently guard this line. The 9/9 pass rate is evidence of no collateral damage, not evidence that the fix works. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: cadc8f9e-e4ac-4074-92ef-88e90df96091
There was a problem hiding this comment.
🟢 Approval recommended
The change is narrowly scoped, well-documented, and now has deterministic unit coverage for the exact predicate that previously regressed.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs:3993
- In this XML doc comment, “server side” should be hyphenated as the compound adjective “server-side” for consistency with other comments (e.g., in TdsParser) and standard usage.
This issue also appears on line 4019 of the same file.
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs:4019
- In this XML doc comment, “server side” should be hyphenated as “server-side” (compound adjective).
/// case resets the server side transaction out from under System.Transactions, which
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Lite
Compound adjective. Comment-only change, no behavior impact. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: cadc8f9e-e4ac-4074-92ef-88e90df96091
There was a problem hiding this comment.
🟢 Approval recommended
The logic change is narrowly scoped, preserves prior behaviors by construction (union of old/new predicates), and is covered by deterministic unit tests that would fail under the known buggy variants.
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 0 new
- Review effort level: Lite
The restored Is2008OrNewer guard reinstated only half of a retired safety mechanism. Pre-dotnet#3019, IsNonPoolableTransactionRoot both suppressed the preserve bit and routed the connection into pool stasis; being parked is what made the plain reset harmless. dotnet#3019 removed the property, and both pools now route on EnlistedTransaction alone, so a delegated root with a null EnlistedTransaction returns to the general pool. Suppressing the preserve bit without the stasis routing therefore reproduces dotnet#4001 on SQL Server 2005 -- which is below the supported floor of 2012 anyway. Predicate is now isPooled && (isTransactionRoot || hasEnlistedTransaction). Tests simplified from 19 to 11; both bug mutants still fail. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: cadc8f9e-e4ac-4074-92ef-88e90df96091
There was a problem hiding this comment.
🔵 Needs a closer look
It changes pooled-connection reset behavior in a transaction-critical path (delegated vs enlisted System.Transactions semantics), which warrants final maintainer review despite the targeted unit coverage.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlConnectionInternalResetTransactionTests.cs:45
- The PR description checklist says “Tests added or updated — no”, but this PR adds/updates unit tests (e.g., this new SqlConnectionInternalResetTransactionTests suite). Please update the PR description checklist/verification section so it matches the actual changes.
- Files reviewed: 3/3 changed files
- Comments generated: 0 new
- Review effort level: Lite
paulmedynski
left a comment
There was a problem hiding this comment.
Remove redundant tests, but preserve their comments.
Keep the regression context beside the corresponding exhaustive theory rows instead of repeating those cases as separate facts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: cadc8f9e-e4ac-4074-92ef-88e90df96091
There was a problem hiding this comment.
🟢 Approval recommended
The change is a narrow, well-justified condition fix with deterministic unit-test coverage of the extracted predicate and clear documentation of the regression and rationale.
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Lite
There was a problem hiding this comment.
🟢 Approval recommended
The logic change is narrowly scoped, directly matches the stated fix condition, and is covered by a deterministic exhaustive unit test for the extracted predicate.
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 0 new
- Review effort level: Lite
Fixes #4001
Summary
A pooled connection could be returned in a broken state after a
TransactionScoperollback. PR #3019 changed reset preservation from checking delegated transaction roots to checking enlisted transactions, fixing #2970 but missing the delegated-root state behind #4001.This change preserves the transaction when a pooled connection is either:
This retains the #2970 behavior while covering #4001.
Verification
📄 Full root cause analysis
Checklist