Fix | Preserve delegated transactions when resetting a pooled connection - #4557
Draft
priyankatiwari08 wants to merge 1 commit into
Draft
Conversation
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
Contributor
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.
Comment on lines
3934
to
3936
| // Pooled connections that are enlisted in a transaction must have their transaction | ||
| // preserved when resetting the connection state. Otherwise, future uses of the connection | ||
| // from the pool will execute outside the transaction, in auto-commit mode. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4001
Summary
A pooled connection could be permanently broken after a
TransactionScoperollback, surfacing to callers as:This is a regression introduced in 6.1.0 by #3019. Confirmed by bisect: 6.0.5 ✅ · 6.1.0 ❌ · 6.1.1 ❌ · 6.1.4 ❌ · main ❌.
Root cause
On this code path a connection can be tied to a transaction in one of two mutually exclusive ways:
IsTransactionRootEnlistedTransactiontruenullfalseResetConnection()decides whether to preserve the server-side transaction across a pool reset:IsTransactionRoot && Pool is not null. That covered the delegated-root case but missed the enlisted case — reported as Azure SQL DTC bug #2970.0322d44c7) replaced it withEnlistedTransaction is not null && Pool is not null. That fixed Azure SQL DTC bug #2970, but silently dropped the delegated-root case.It was a swap, not a widening. So when a connection is returned to the pool while still the root of a live delegated transaction,
preserveTransactionisfalse, and the TDS reset wipes the server-side transaction whileSystem.Transactionsstill believes it exists. When the scope later disposes and rolls back,SqlDelegatedTransaction.Rollbackfails and callsDoomThisConnection(). With a small pool that same doomed physical connection is handed straight back out.Runtime instrumentation at the failing reset confirms the state exactly:
root=Truewithenlisted=nullis precisely the case the current condition fails to cover.The fix
Preserve the transaction when either condition holds:
This is a strict superset of both the pre-#3019 and post-#3019 behavior, so by construction it cannot regress #2970 or anything else that relied on either condition.
Verification
Reporter's repro (NHibernate 5.5.2,
MaxPoolSize=1,TransactionScopewith a failed DTC promotion), run against both connection pool implementations:WaitHandleDbConnectionPool(default)ChannelDbConnectionPool(UseConnectionPoolV2)Manual test suite:
--filter "FullyQualifiedName~TransactionTest"→ 9/9 passing, including the #2970 regression coverage.On the absence of a new automated test
The only known reliable repro requires NHibernate. The bug needs the connection caught in a narrow half-state — still the delegated root, but with
EnlistedTransactionalready detached byDetachCurrentTransactionIfEnded(). NHibernate'sStatelessSessionreaches it because it returns the connection to the pool between every statement; hand-written SqlClient code holds connections across statements and skips past the window. Eight NHibernate-free variants were attempted and none reproduced.Rather than add a heavyweight third-party test dependency to the manual suite, this ships relying on the existing
TransactionTestcoverage, which guards the #2970 half of the condition. The fix being a strict superset means the remaining risk is confined to under-resetting, not incorrect behavior. Happy to revisit if reviewers would prefer an NHibernate-backed test.Checklist
TransactionTestsuite (9/9) used as the guardSuggested release note entry
Related