Sitelet https://github.com/dotnet/SqlClient/pull/4557
Skip to content

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

Merged
priyankatiwari08 merged 9 commits into
dotnet:mainfrom
priyankatiwari08:priyankatiwari08-fix-4001-preserve-delegated-transaction
Sep 10, 2026
Merged

priyankatiwari08 merged 9 commits into
dotnet:mainfrom
priyankatiwari08:priyankatiwari08-fix-4001-preserve-delegated-transaction

Conversation

@priyankatiwari08

@priyankatiwari08 priyankatiwari08 commented Aug 20, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #4001

Summary

A pooled connection could be returned in a broken state after a TransactionScope rollback. 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:

  • a delegated transaction root, or
  • enlisted in a transaction.
isPooled && (isTransactionRoot || hasEnlistedTransaction)

This retains the #2970 behavior while covering #4001.

Verification

📄 Full root cause analysis

Checklist

  • Tests added or updated
  • Public API changes documented — no public API changes
  • Verified against customer repro
  • No breaking changes introduced

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.

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

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 Is2008OrNewer safeguard, but SQL Server 2005 is still an accepted server version (tests/UnitTests/SimulatedServerTests/ConnectionTests.cs:768 and TdsEnums.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 only EnlistedTransaction to 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 because Activate(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, but SqlInternalConnectionTds.EnlistNonNull assigns EnlistedTransaction = transaction even when promotable delegation succeeds. null is 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));

Copilot AI review requested due to automatic review settings August 21, 2026 11:15
@priyankatiwari08
priyankatiwari08 marked this pull request as ready for review August 21, 2026 11:18
@priyankatiwari08
priyankatiwari08 requested a review from a team as a code owner August 21, 2026 11:18

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

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 IsNonPoolableTransactionRoot logic required Is2008OrNewer && Pool != null for 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 sends ST_RESET_CONNECTION_PRESERVE_TRANSACTION. Keep the guard only for IsTransactionRoot while 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, but SqlConnectionInternal.EnlistNonNull assigns that property after a successful delegated enlistment; the same note also lists the true/set combination 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 EnlistNonNull path assigns EnlistedTransaction after 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 checking IsTransactionRoot rather 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 == true case (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

codecov Bot commented Aug 21, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.77778% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 63.47%. Comparing base (368e5ac) to head (89a6b16).
⚠️ Report is 19 commits behind head on main.

Files with missing lines Patch % Lines
...Data/SqlClient/Connection/SqlConnectionInternal.cs 77.77% 2 Missing ⚠️
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     
Flag Coverage Δ
CI-SqlClient ?
PR-SqlClient-Project 63.47% <77.77%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@priyankatiwari08 priyankatiwari08 moved this from To triage to In progress in SqlClient Board Aug 24, 2026
@priyankatiwari08 priyankatiwari08 added this to the 7.1.0-preview3 milestone Aug 24, 2026

@paulmedynski paulmedynski 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.

Where is the new test (or tests) that fails before this change, and passes afterwards?

@github-project-automation github-project-automation Bot moved this from In progress to Waiting for customer in SqlClient Board Aug 24, 2026

@mdaigle mdaigle 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.

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.

@mdaigle mdaigle added the Author attention needed PRs that require author to respond or make updates to PR. label Aug 26, 2026
@github-actions

Copy link
Copy Markdown

@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 /ready to remove the label and re-engage reviewers.

priyankatiwari08 and others added 2 commits August 28, 2026 13:31
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
@priyankatiwari08
priyankatiwari08 marked this pull request as ready for review September 2, 2026 11:16

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.

🟢 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
Copilot AI review requested due to automatic review settings September 2, 2026 11:38

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.

🟢 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

@priyankatiwari08 priyankatiwari08 modified the milestones: 8.0.0-preview1, 7.1.0 Sep 4, 2026
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
Copilot AI review requested due to automatic review settings September 4, 2026 08:18

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.

🔵 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 paulmedynski 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.

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
Copilot AI review requested due to automatic review settings September 7, 2026 14:44

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.

🟢 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

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.

🟢 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

@priyankatiwari08
priyankatiwari08 enabled auto-merge (squash) September 10, 2026 08:40
@priyankatiwari08
priyankatiwari08 merged commit 5b34b39 into dotnet:main Sep 10, 2026
209 of 212 checks passed
@github-project-automation github-project-automation Bot moved this from Waiting for customer to Done in SqlClient Board Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Pooled connection corrupted after TransactionScope rollback with failed DTC promotion

5 participants