Skip to content

Fix | Preserve delegated transactions when resetting a pooled connection - #4557

Draft
priyankatiwari08 wants to merge 1 commit into
dotnet:mainfrom
priyankatiwari08:priyankatiwari08-fix-4001-preserve-delegated-transaction
Draft

Fix | Preserve delegated transactions when resetting a pooled connection#4557
priyankatiwari08 wants to merge 1 commit into
dotnet:mainfrom
priyankatiwari08:priyankatiwari08-fix-4001-preserve-delegated-transaction

Conversation

@priyankatiwari08

Copy link
Copy Markdown
Contributor

Fixes #4001

Summary

A pooled connection could be permanently broken after a TransactionScope rollback, surfacing to callers as:

InvalidOperationException: The requested operation cannot be completed because the connection has been broken.

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:

Case Meaning IsTransactionRoot EnlistedTransaction
Delegated root The transaction was delegated down to this connection and lives on it true null
Enlisted participant The connection joined a transaction owned elsewhere false set

ResetConnection() decides whether to preserve the server-side transaction across a pool reset:

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, preserveTransaction is false, and the TDS reset wipes the server-side transaction while System.Transactions still believes it exists. When the scope later disposes and rolls back, SqlDelegatedTransaction.Rollback fails and calls DoomThisConnection(). With a small pool that same doomed physical connection is handed straight back out.

Runtime instrumentation at the failing reset confirms the state exactly:

[RESET] preserve=False  root=True  delegated.IsActive=True  enlisted=null  pool=set
[DOOM]  <- SqlDelegatedTransaction.Rollback  <- Transaction.Rollback  <- TransactionScope.Dispose

root=True with enlisted=null is precisely the case the current condition fails to cover.

The fix

Preserve the transaction when either condition holds:

_parser.PrepareResetConnection(
    Pool is not null &&
    (IsTransactionRoot || EnlistedTransaction is not null));

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, TransactionScope with a failed DTC promotion), run against both connection pool implementations:

without fix with fix
WaitHandleDbConnectionPool (default) ❌ reproduces ✅ passes
ChannelDbConnectionPool (UseConnectionPoolV2) ❌ reproduces ✅ passes

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 EnlistedTransaction already detached by DetachCurrentTransactionIfEnded(). NHibernate's StatelessSession reaches 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 TransactionTest coverage, 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

  • Tests added or updated — see the section above; existing TransactionTest suite (9/9) used as the guard
  • Public API changes documented — none, this is an internal behavior fix
  • Verified against customer repro
  • Ensure no breaking changes introduced

Suggested release note entry

Fixed

Fixed a regression where a pooled connection could be left in a broken state after a TransactionScope rollback, causing subsequent uses of that connection to fail with "The requested operation cannot be completed because the connection has been broken." The connection reset logic now preserves the transaction when the connection is the root of a delegated transaction, in addition to when it is enlisted in an external transaction. (#4001)

Related

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
Copilot AI lite review requested due to automatic review settings August 20, 2026 11:07
@github-project-automation github-project-automation Bot moved this to To triage in SqlClient Board Aug 20, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 PrepareResetConnection preserve-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.
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.

Pooled connection corrupted after TransactionScope rollback with failed DTC promotion Azure SQL DTC bug

2 participants