Skip to content

Commit 9f67fb1

Browse files
Reset session isolation level on pool return (fixes #96)
SqlTransaction and TransactionScope leave the underlying SQL Server session with the elevated isolation level after Commit/Rollback. sp_reset_connection does not reset it, so the next user of the pooled physical connection silently inherits it. This change tracks when a non-default isolation level was set via the TM Begin path and, on pool return, issues a 'SET TRANSACTION ISOLATION LEVEL READ COMMITTED' batch right before sp_reset_connection (piggybacked on the same TDS header, no extra round trip). On failure the connection is doomed instead of returned to the pool. A new AppContext switch 'Switch.Microsoft.Data.SqlClient.UseLegacyIsolationLevelBehavior' preserves previous behavior for callers that depend on it (default: false). Adds IsolationLevelLeakTest under ManualTests covering the two repro scenarios plus a negative test for the kill switch.
1 parent bfbdd30 commit 9f67fb1

4 files changed

Lines changed: 233 additions & 0 deletions

File tree

src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,14 @@ internal class SqlConnectionInternal : DbConnectionInternal, IDisposable
281281
// @TODO: Rename to match naming conventions (remove f prefix)
282282
private readonly bool _fResetConnection;
283283

284+
// Tracks whether a SQL Server session transaction isolation level
285+
// change has been issued on this connection (via SqlTransaction or
286+
// System.Transactions enlistment) but not yet undone. SQL Server's
287+
// sp_reset_connection does not reset the session isolation level, so
288+
// without compensation a pooled connection leaks the level to its
289+
// next user.
290+
private bool _isolationLevelDirty;
291+
284292

285293

286294
// @TODO: Rename to match naming conventions
@@ -2679,6 +2687,16 @@ private void ExecuteTransaction2005(
26792687
internalTransaction,
26802688
stateObj,
26812689
isDelegateControlRequest);
2690+
2691+
// A successful Begin with a non-default isolation level mutates
2692+
// SQL Server's session transaction_isolation_level. sp_reset_connection
2693+
// will not undo this, so remember it and reset on deactivate.
2694+
if (requestType == TdsEnums.TransactionManagerRequestType.Begin &&
2695+
isoLevel != TdsEnums.TransactionManagerIsolationLevel.Unspecified &&
2696+
isoLevel != TdsEnums.TransactionManagerIsolationLevel.ReadCommitted)
2697+
{
2698+
_isolationLevelDirty = true;
2699+
}
26822700
}
26832701
finally
26842702
{
@@ -3894,6 +3912,42 @@ internal override void ResetConnection()
38943912
// Reset dictionary values, since calling reset will not send us env_changes.
38953913
CurrentDatabase = _originalDatabase;
38963914
_currentLanguage = _originalLanguage;
3915+
3916+
// sp_reset_connection (set up by PrepareResetConnection above) does not reset the
3917+
// session transaction isolation level. When we know a previous Begin changed the
3918+
// level, issue an explicit SET so the next user of this pooled connection starts at
3919+
// READ COMMITTED. The batch piggybacks the queued sp_reset_connection on its TDS
3920+
// header, so the round trip is shared and no separate reset packet is sent later.
3921+
// Gated by an app-context switch for back-compat.
3922+
if (_isolationLevelDirty && !LocalAppContextSwitches.UseLegacyIsolationLevelBehavior)
3923+
{
3924+
ResetSessionIsolationLevel();
3925+
}
3926+
}
3927+
}
3928+
3929+
// Issues "SET TRANSACTION ISOLATION LEVEL READ COMMITTED;" on the physical state object
3930+
// so the next user of this pooled connection observes the default session isolation level.
3931+
// Called only from ResetConnection when _isolationLevelDirty is set; runs synchronously
3932+
// because Deactivate is a synchronous pool-return path.
3933+
private void ResetSessionIsolationLevel()
3934+
{
3935+
try
3936+
{
3937+
_parser.TdsExecuteSQLBatch(
3938+
text: "SET TRANSACTION ISOLATION LEVEL READ COMMITTED;",
3939+
timeout: 0,
3940+
notificationRequest: null,
3941+
stateObj: _parser._physicalStateObj,
3942+
sync: true);
3943+
_parser.Run(RunBehavior.UntilDone, null, null, null, _parser._physicalStateObj);
3944+
_isolationLevelDirty = false;
3945+
}
3946+
catch (Exception e) when (ADP.IsCatchableExceptionType(e))
3947+
{
3948+
// If we cannot scrub the session state, the connection is not safe to pool.
3949+
// Dooming forces Deactivate's outer logic to destroy it instead of pooling it.
3950+
DoomThisConnection();
38973951
}
38983952
}
38993953

src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,14 @@ internal static class LocalAppContextSwitches
6565
private const string UseLegacyFailoverAlternationOnLoginSqlErrorsString =
6666
"Switch.Microsoft.Data.SqlClient.UseLegacyFailoverAlternationOnLoginSqlErrors";
6767

68+
/// <summary>
69+
/// The name of the app context switch that controls whether pooled connections
70+
/// preserve the legacy behavior of leaking the SQL Server session
71+
/// transaction isolation level across pool reuse.
72+
/// </summary>
73+
private const string UseLegacyIsolationLevelBehaviorString =
74+
"Switch.Microsoft.Data.SqlClient.UseLegacyIsolationLevelBehavior";
75+
6876
/// <summary>
6977
/// The name of the app context switch that controls whether to preserve
7078
/// legacy behavior where Timestamp/RowVersion fields return empty byte
@@ -201,6 +209,11 @@ private enum SwitchValue : byte
201209
/// </summary>
202210
private static SwitchValue s_useLegacyFailoverAlternationOnLoginSqlErrors = SwitchValue.None;
203211

212+
/// <summary>
213+
/// The cached value of the UseLegacyIsolationLevelBehavior switch.
214+
/// </summary>
215+
private static SwitchValue s_useLegacyIsolationLevelBehavior = SwitchValue.None;
216+
204217
/// <summary>
205218
/// The cached value of the LegacyRowVersionNullBehavior switch.
206219
/// </summary>
@@ -446,6 +459,22 @@ public static bool GlobalizationInvariantMode
446459
defaultValue: false,
447460
ref s_useLegacyFailoverAlternationOnLoginSqlErrors);
448461

462+
/// <summary>
463+
/// When set to true, pooled connections preserve the legacy behavior where
464+
/// the SQL Server session transaction isolation level is not reset on
465+
/// return to the pool. As a result, the next user of the pooled connection
466+
/// may inherit a non-default isolation level.
467+
///
468+
/// The default value of this switch is false, meaning the driver will
469+
/// reset the isolation level to READ COMMITTED before the next checkout
470+
/// when it knows the level was changed.
471+
/// </summary>
472+
public static bool UseLegacyIsolationLevelBehavior =>
473+
AcquireAndReturn(
474+
UseLegacyIsolationLevelBehaviorString,
475+
defaultValue: false,
476+
ref s_useLegacyIsolationLevelBehavior);
477+
449478
/// <summary>
450479
/// In System.Data.SqlClient and Microsoft.Data.SqlClient prior to 3.0.0 a
451480
/// field with type Timestamp/RowVersion would return an empty byte array.

src/Microsoft.Data.SqlClient/tests/ManualTests/Microsoft.Data.SqlClient.ManualTests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@
233233
<Compile Include="SQL\SqlSchemaInfoTest\SqlSchemaInfoTest.cs" />
234234
<Compile Include="SQL\SqlStatisticsTest\SqlStatisticsTest.cs" />
235235
<Compile Include="SQL\TransactionTest\DistributedTransactionTest.cs" />
236+
<Compile Include="SQL\TransactionTest\IsolationLevelLeakTest.cs" />
236237
<Compile Include="SQL\TransactionTest\TransactionEnlistmentTest.cs" />
237238
<Compile Include="SQL\TransactionTest\TransactionTest.cs" />
238239
<Compile Include="SQL\UdtTest\SqlServerTypesTest.cs" />
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using System;
6+
using System.Data;
7+
using System.Transactions;
8+
using Xunit;
9+
using IsolationLevel = System.Data.IsolationLevel;
10+
11+
namespace Microsoft.Data.SqlClient.ManualTesting.Tests
12+
{
13+
// SqlTransaction / TransactionScope used to leave the pooled connection
14+
// with the elevated session isolation level. The next user of the pooled
15+
// connection would silently inherit it. The fix resets the session
16+
// isolation level to READ COMMITTED when the connection is returned to
17+
// the pool.
18+
public static class IsolationLevelLeakTest
19+
{
20+
private const string GetIsoSql = @"
21+
SELECT CASE transaction_isolation_level
22+
WHEN 0 THEN 'Unspecified'
23+
WHEN 1 THEN 'ReadUncommitted'
24+
WHEN 2 THEN 'ReadCommitted'
25+
WHEN 3 THEN 'RepeatableRead'
26+
WHEN 4 THEN 'Serializable'
27+
WHEN 5 THEN 'Snapshot'
28+
END
29+
FROM sys.dm_exec_sessions WHERE session_id = @@SPID;";
30+
31+
private static string PooledMaxOneConnString =>
32+
new SqlConnectionStringBuilder(DataTestUtility.TCPConnectionString)
33+
{
34+
Pooling = true,
35+
MaxPoolSize = 1,
36+
MultipleActiveResultSets = false,
37+
ApplicationName = "IsoLeakTest"
38+
}.ConnectionString;
39+
40+
private static int GetSpid(SqlConnection c)
41+
{
42+
using SqlCommand cmd = new SqlCommand("SELECT @@SPID;", c);
43+
return Convert.ToInt32(cmd.ExecuteScalar());
44+
}
45+
46+
private static string GetIso(SqlConnection c)
47+
{
48+
using SqlCommand cmd = new SqlCommand(GetIsoSql, c);
49+
return (string)cmd.ExecuteScalar();
50+
}
51+
52+
[ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
53+
public static void SqlTransaction_SerializableDoesNotLeakAcrossPool()
54+
{
55+
string cs = PooledMaxOneConnString;
56+
int spid1;
57+
using (SqlConnection c = new SqlConnection(cs))
58+
{
59+
c.Open();
60+
spid1 = GetSpid(c);
61+
using SqlTransaction tx = c.BeginTransaction(IsolationLevel.Serializable);
62+
Assert.Equal("Serializable", GetIsoOnTx(c, tx));
63+
tx.Rollback();
64+
}
65+
66+
using (SqlConnection c = new SqlConnection(cs))
67+
{
68+
c.Open();
69+
Assert.Equal(spid1, GetSpid(c)); // pool reuse
70+
Assert.Equal("ReadCommitted", GetIso(c));
71+
}
72+
}
73+
74+
[ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
75+
public static void TransactionScope_SerializableDoesNotLeakAcrossPool()
76+
{
77+
string cs = PooledMaxOneConnString;
78+
int spid1;
79+
using (var scope = new TransactionScope(
80+
TransactionScopeOption.RequiresNew,
81+
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.Serializable },
82+
TransactionScopeAsyncFlowOption.Enabled))
83+
using (SqlConnection c = new SqlConnection(cs))
84+
{
85+
c.Open();
86+
spid1 = GetSpid(c);
87+
Assert.Equal("Serializable", GetIso(c));
88+
scope.Complete();
89+
}
90+
91+
using (SqlConnection c = new SqlConnection(cs))
92+
{
93+
c.Open();
94+
Assert.Equal(spid1, GetSpid(c));
95+
Assert.Equal("ReadCommitted", GetIso(c));
96+
}
97+
}
98+
99+
// Negative test: legacy switch ON brings the old leak back. Runs in
100+
// an isolated AppDomain on .NET Framework / process boundary on .NET
101+
// would be ideal, but AppContext switches set before any pool entry
102+
// is created suffice when this test runs first in its own collection.
103+
[ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
104+
public static void LegacySwitch_PreservesOldLeakBehavior()
105+
{
106+
const string Switch = "Switch.Microsoft.Data.SqlClient.UseLegacyIsolationLevelBehavior";
107+
AppContext.SetSwitch(Switch, true);
108+
try
109+
{
110+
// Use a distinct app name so this test gets its own pool group
111+
// and isn't affected by entries created by the other tests.
112+
string cs = new SqlConnectionStringBuilder(DataTestUtility.TCPConnectionString)
113+
{
114+
Pooling = true,
115+
MaxPoolSize = 1,
116+
MultipleActiveResultSets = false,
117+
ApplicationName = "IsoLeakTest-Legacy"
118+
}.ConnectionString;
119+
120+
int spid1;
121+
using (SqlConnection c = new SqlConnection(cs))
122+
{
123+
c.Open();
124+
spid1 = GetSpid(c);
125+
using SqlTransaction tx = c.BeginTransaction(IsolationLevel.Serializable);
126+
tx.Rollback();
127+
}
128+
129+
using (SqlConnection c = new SqlConnection(cs))
130+
{
131+
c.Open();
132+
Assert.Equal(spid1, GetSpid(c));
133+
Assert.Equal("Serializable", GetIso(c)); // legacy: leaks
134+
}
135+
}
136+
finally
137+
{
138+
AppContext.SetSwitch(Switch, false);
139+
SqlConnection.ClearAllPools();
140+
}
141+
}
142+
143+
private static string GetIsoOnTx(SqlConnection c, SqlTransaction tx)
144+
{
145+
using SqlCommand cmd = new SqlCommand(GetIsoSql, c, tx);
146+
return (string)cmd.ExecuteScalar();
147+
}
148+
}
149+
}

0 commit comments

Comments
 (0)