Skip to content

Commit 983bf54

Browse files
Docs | Correct and expand the RCA note for #4001
Fixes two factual errors flagged in review: - A delegated transaction root does not always have a null EnlistedTransaction. The property is set unconditionally on enlistment; null is specific to the transient half state left behind by DetachCurrentTransactionIfEnded. - Is2008OrNewer is not vestigial. SQL Server 2005 is still an accepted TDS version, so the guard can be false. Also documents the new regression tests, why an end-to-end reproduction was abandoned in favour of a helper level test, and removes an inaccurate claim that the new condition is a strict superset of the pre-#3019 one. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: cadc8f9e-e4ac-4074-92ef-88e90df96091
1 parent 1059dbc commit 983bf54

1 file changed

Lines changed: 95 additions & 24 deletions

File tree

doc/design-notes/4001-delegated-transaction-reset.md

Lines changed: 95 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ what the existing test suite does and does not actually verify.
1919
This distinction is the crux of the entire bug.
2020

2121
When a connection participates in a `TransactionScope`, it ends up in one of **two
22-
mutually exclusive** states.
22+
distinct** states. They overlap, but neither implies the other.
2323

2424
### Delegated root — "I *own* this transaction"
2525

@@ -28,10 +28,26 @@ down to SQL Server rather than paying for a distributed coordinator. The transac
2828
lives **on** the connection.
2929

3030
- `IsTransactionRoot``true`
31-
- `EnlistedTransaction`**`null`**
31+
- `EnlistedTransaction`set at first, then **cleared** later (see below)
3232

33-
The `null` is not an oversight. There is no external transaction object to point at,
34-
because the transaction *is here*.
33+
`IsTransactionRoot` is not a stored flag. It is derived:
34+
35+
```csharp
36+
internal bool IsTransactionRoot => DelegatedTransaction?.IsActive == true;
37+
```
38+
39+
Enlistment sets `EnlistedTransaction` unconditionally, so a *freshly* delegated root
40+
has both. But once the transaction is no longer `Active`,
41+
`DbConnectionInternal.DetachCurrentTransactionIfEnded` clears `EnlistedTransaction`:
42+
43+
```csharp
44+
transactionIsDead = enlistedTransaction.TransactionInformation.Status != TransactionStatus.Active;
45+
if (transactionIsDead) { DetachTransaction(enlistedTransaction, true); }
46+
```
47+
48+
The delegated transaction, meanwhile, can still report `IsActive == true`. That
49+
transient **half-state** — root, but no `EnlistedTransaction` — is precisely the state
50+
issue #4001 reproduces in.
3551

3652
### Enlisted participant — "I *joined* someone else's transaction"
3753

@@ -43,9 +59,10 @@ each connection enlists in it.
4359

4460
### The trap
4561

46-
These two states never look alike. **A delegated root always has a `null`
47-
`EnlistedTransaction`.** Any check that tests only one of these fields silently
48-
misses the other case — and does so with no exception at the point of the mistake.
62+
Neither field subsumes the other: a delegated root can have a `null`
63+
`EnlistedTransaction`, and an enlisted participant is never a root. Any check that
64+
tests only one of these fields silently misses the other case — and does so with no
65+
exception at the point of the mistake.
4966

5067
---
5168

@@ -96,13 +113,17 @@ internal protected override bool IsNonPoolableTransactionRoot
96113
=> IsTransactionRoot && (!Is2008OrNewer || Pool == null);
97114
```
98115

99-
Since `Is2008OrNewer` is true for every supported server, the entire pre-#3019
100-
condition reduces to:
116+
Substituting, the pre-#3019 condition was:
101117

102118
```csharp
103-
IsTransactionRoot && Pool != null
119+
IsTransactionRoot && Is2008OrNewer && Pool != null
104120
```
105121

122+
The `Is2008OrNewer` term is not vestigial. `AdapterUtil.ValidateTdsVersion` still accepts
123+
`TdsEnums.SQL2005_VERSION`, and `ConnectionCapabilities.Is2008R2OrNewer` returns `false`
124+
for it. A pre-2008 server cannot carry a delegated transaction across a reset, so such a
125+
connection must not be recycled with one attached.
126+
106127
So the two conditions were:
107128

108129
| | Question it asked | Covered | Missed |
@@ -120,13 +141,26 @@ This reframes the fix: the goal is not to undo #3019, it is to finish it.
120141

121142
## 4. The fix
122143

144+
The predicate is extracted into a helper so it can be tested directly:
145+
123146
```csharp
124-
_parser.PrepareResetConnection(
125-
Pool is not null &&
126-
(IsTransactionRoot || EnlistedTransaction is not null));
147+
internal static bool ShouldPreserveTransactionOnReset(
148+
bool isPooled,
149+
bool isTransactionRoot,
150+
bool is2008OrNewer,
151+
bool hasEnlistedTransaction)
152+
{
153+
if (!isPooled)
154+
{
155+
return false;
156+
}
157+
158+
return (isTransactionRoot && is2008OrNewer) || hasEnlistedTransaction;
159+
}
127160
```
128161

129-
This is exactly `OLD || NEW`.
162+
This is exactly `OLD || NEW`, with both prior conditions preserved verbatim — including
163+
the `Is2008OrNewer` guard that only ever applied to the delegated-root arm.
130164

131165
---
132166

@@ -135,21 +169,23 @@ This is exactly `OLD || NEW`.
135169
This is the question that matters most, and it is answerable by inspection rather than
136170
by testing. Every reachable state, for a pooled connection:
137171

138-
| `IsTransactionRoot` | `EnlistedTransaction` | Pre-#3019 | #3019 | **This fix** |
139-
|:---:|:---:|:---:|:---:|:---:|
140-
| `false` | `null` | `false` | `false` | `false` |
141-
| **`true`** | **`null`** |`true` |`false`**#4001** |**`true`** |
142-
| **`false`** | **set** |`false`**#2970** |`true` |**`true`** |
143-
| `true` | set | `true` | `true` | `true` |
172+
| `IsTransactionRoot` | `Is2008OrNewer` | `EnlistedTransaction` | Pre-#3019 | #3019 | **This fix** |
173+
|:---:|:---:|:---:|:---:|:---:|:---:|
174+
| `false` | `true` | `null` | `false` | `false` | `false` |
175+
| **`true`** | **`true`** | **`null`** |`true` |`false`**#4001** |**`true`** |
176+
| `true` | `false` | `null` | `false` | `false` | `false` |
177+
| **`false`** | any | **set** |`false`**#2970** |`true` |**`true`** |
178+
| `true` | any | set | see above | `true` | `true` |
144179

145180
Read the **#2970 row**. That is the row PR #3019 was created to fix, and this fix still
146181
evaluates `true` there. It is untouched.
147182

148183
Reintroducing #2970 would require that cell to flip to `false`, and `A || B` cannot
149184
evaluate `false` while `B` is `true`. The guarantee is structural, not empirical.
150185

151-
The condition is a strict **superset** of both prior behaviors. There is no input on
152-
which it returns `false` where either predecessor returned `true`.
186+
Because each arm reproduces its original predecessor exactly, the condition returns
187+
`true` wherever either predecessor did, and never returns `false` where one of them
188+
returned `true`.
153189

154190
---
155191

@@ -230,8 +266,43 @@ to cover #2970 — is tagged `[Trait("Category", "flaky")]` and passes against c
230266
carries the #2970 bug.
231267

232268
The practical conclusion: **the 9/9 result is evidence of no collateral damage, not
233-
evidence that the fix works.** The evidence that the fix works is the reproduction
234-
matrix above and the structural argument in section 5. Those should carry the weight.
269+
evidence that the fix works.**
270+
271+
### The regression test that was added
272+
273+
An end-to-end reproduction was attempted extensively and abandoned. The `#4001` state
274+
requires a narrow simultaneity — the transaction's status already non-`Active` (so
275+
`DetachCurrentTransactionIfEnded` has cleared `EnlistedTransaction`) while
276+
`DelegatedTransaction.IsActive` is still `true` — and which of two teardown paths in
277+
`SqlDelegatedTransaction` wins is a race:
278+
279+
- `TransactionEnded` sets `_active = false` and *immediately* calls
280+
`DoomThisConnection()`. If this path runs first, the delegate is already inactive
281+
before any reset, so the state is never observed.
282+
- `Rollback`, driven from `TransactionScope.Dispose`, sets `_active = false` *after* the
283+
reset. This is the ordering the reporter hit.
284+
285+
Roughly twenty harness variants — varying pool size, pool implementation, promotion
286+
success, explicit rollback, and parking the delegate in the transacted pool ahead of the
287+
enlistment — consistently drove the first path. A test built on that race would be
288+
flaky, which is the same defect `Test_EnlistedTransactionPreservedWhilePooled` already
289+
demonstrates.
290+
291+
Instead the predicate was extracted into
292+
`SqlConnectionInternal.ShouldPreserveTransactionOnReset` and pinned directly by
293+
`SqlConnectionInternalResetTransactionTests`, following the existing precedent of
294+
`ResolveLoginTimeout` / `SqlConnectionInternalTimeoutTests`. The tests were themselves
295+
mutation-tested:
296+
297+
| Condition compiled into the helper | Bug it contains | New tests |
298+
|---|---|---|
299+
| `hasEnlistedTransaction` (#3019) | **#4001** | ❌ 2 failed |
300+
| `isTransactionRoot && is2008OrNewer` (pre-#3019) | **#2970** | ❌ 4 failed |
301+
| Union without the 2008 guard | pre-2008 regression | ❌ 2 failed |
302+
| The shipped condition | none | ✅ 15 passed |
303+
304+
This satisfies "fails before the change, passes after" for **both** regressions, and
305+
does so deterministically and without a server.
235306

236307
---
237308

0 commit comments

Comments
 (0)