Skip to content

Commit 6e3affa

Browse files
authored
Merge pull request #218 from caleblloyd/f_reaper
Add Connection Pool Reaper & ConnectionIdleTimeout.
2 parents 739101d + ff5de06 commit 6e3affa

File tree

11 files changed

+204
-36
lines changed

11 files changed

+204
-36
lines changed

.ci/test.ps1

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ dotnet test tests\SideBySide\SideBySide.csproj -c Release
2424
if ($LASTEXITCODE -ne 0){
2525
exit $LASTEXITCODE;
2626
}
27+
echo "Executing Debug Only tests"
28+
dotnet test tests\SideBySide\SideBySide.csproj -c Debug --filter "FullyQualifiedName~SideBySide.DebugOnlyTests"
29+
if ($LASTEXITCODE -ne 0){
30+
exit $LASTEXITCODE;
31+
}
2732

2833
echo "Executing tests with Compression, No SSL"
2934
Copy-Item -Force .ci\config\config.compression.json tests\SideBySide\config.json

.travis.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ script:
1818
- dotnet test tests/MySqlConnector.Tests/MySqlConnector.Tests.csproj -c Release -f netcoreapp1.1.1
1919
- dotnet build tests/SideBySide/SideBySide.csproj -c Release -f netcoreapp1.1.1
2020
- echo 'Executing tests with No Compression, No SSL' && ./.ci/use-config.sh config.json 172.17.0.1 3307 && time dotnet test tests/SideBySide/SideBySide.csproj -c Release -f netcoreapp1.1.1
21+
- echo 'Executing Debug Only tests' && time dotnet test tests/SideBySide/SideBySide.csproj -c Debug -f netcoreapp1.1.1 --filter "FullyQualifiedName~SideBySide.DebugOnlyTests"
2122
- echo 'Executing tests with Compression, No SSL' && ./.ci/use-config.sh config.compression.json 172.17.0.1 3307 && time dotnet test tests/SideBySide/SideBySide.csproj -c Release -f netcoreapp1.1.1
2223
- echo 'Executing tests with No Compression, SSL' && ./.ci/use-config.sh config.ssl.json 172.17.0.1 3307 && time dotnet test tests/SideBySide/SideBySide.csproj -c Release -f netcoreapp1.1.1
2324
- echo 'Executing tests with Compression, SSL' && ./.ci/use-config.sh config.compression+ssl.json 172.17.0.1 3307 && time dotnet test tests/SideBySide/SideBySide.csproj -c Release -f netcoreapp1.1.1

docs/content/connection-options.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,18 +106,28 @@ Connection pooling is enabled by default. These options are used to configure i
106106
<tr>
107107
<td>Connection Lifetime, ConnectionLifeTime</td>
108108
<td>0</td>
109-
<td>When a connection is returned to the pool, its creation time is compared with the current time, and the connection is destroyed if that time span (in seconds) exceeds the value specified by Connection Lifetime. This is useful in clustered configurations to force load balancing between a running server and a server just brought online. A value of zero (0) causes pooled connections to have the maximum connection timeout.</td>
109+
<td>When a connection is returned to the pool, its creation time is compared with the current time, and the connection is destroyed if that time span (in seconds) exceeds the value specified by Connection Lifetime. This is useful in clustered configurations to force load balancing between a running server and a server just brought online. A value of zero (0) means pooled connections will never incur a ConnectionLifeTime timeout.</td>
110110
</tr>
111111
<tr>
112112
<td>Connection Reset, ConnectionReset </td>
113113
<td>false</td>
114114
<td>If true, the connection state is reset when it is retrieved from the pool. The default value of false avoids making an additional server round trip when obtaining a connection, but the connection state is not reset.</td>
115115
</tr>
116+
<tr>
117+
<td>Connection Idle Timeout, ConnectionIdleTimeout</td>
118+
<td>180</td>
119+
<td>The amount of time in seconds that a connection can remain idle in the pool. Any connection that is idle for longer is subject to being closed by a background task that runs every minute, unless there are only MinimumPoolSize connections left in the pool. A value of zero (0) means pooled connections will never incur a ConnectionIdleTimeout.</td>
120+
</tr>
116121
<tr>
117122
<td>Maximum Pool Size, Max Pool Size, MaximumPoolsize, maxpoolsize</td>
118123
<td>100</td>
119124
<td>The maximum number of connections allowed in the pool.</td>
120125
</tr>
126+
<tr>
127+
<td>Minimum Pool Size, Min Pool Size, MinimumPoolSize, minpoolsize</td>
128+
<td>0</td>
129+
<td>The minimum number of connections to leave in the pool if ConnectionIdleTimeout is reached.</td>
130+
</tr>
121131
</table>
122132

123133
Other Options

src/MySqlConnector/MySqlClient/ConnectionPool.cs

Lines changed: 110 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,17 @@ public async Task<MySqlSession> GetSessionAsync(IOBehavior ioBehavior, Cancellat
2222

2323
try
2424
{
25-
// check for a pooled session
26-
if (m_sessions.TryDequeue(out var session))
25+
// check for a waiting session
26+
MySqlSession session = null;
27+
lock (m_sessions)
28+
{
29+
if (m_sessions.Count > 0)
30+
{
31+
session = m_sessions.First.Value;
32+
m_sessions.RemoveFirst();
33+
}
34+
}
35+
if (session != null)
2736
{
2837
if (session.PoolGeneration != m_generation || !await session.TryPingAsync(ioBehavior, cancellationToken).ConfigureAwait(false))
2938
{
@@ -42,6 +51,7 @@ public async Task<MySqlSession> GetSessionAsync(IOBehavior ioBehavior, Cancellat
4251
}
4352
}
4453

54+
// create a new session
4555
session = new MySqlSession(this, m_generation);
4656
await session.ConnectAsync(m_connectionSettings, ioBehavior, cancellationToken).ConfigureAwait(false);
4757
return session;
@@ -73,7 +83,8 @@ public void Return(MySqlSession session)
7383
try
7484
{
7585
if (SessionIsHealthy(session))
76-
m_sessions.Enqueue(session);
86+
lock (m_sessions)
87+
m_sessions.AddFirst(session);
7788
else
7889
session.DisposeAsync(IOBehavior.Synchronous, CancellationToken.None).ConfigureAwait(false);
7990
}
@@ -87,45 +98,85 @@ public async Task ClearAsync(IOBehavior ioBehavior, CancellationToken cancellati
8798
{
8899
// increment the generation of the connection pool
89100
Interlocked.Increment(ref m_generation);
101+
await CleanPoolAsync(ioBehavior, session => session.PoolGeneration != m_generation, false, cancellationToken).ConfigureAwait(false);
102+
}
90103

91-
var waitTimeout = TimeSpan.FromMilliseconds(10);
92-
while (true)
104+
public async Task ReapAsync(IOBehavior ioBehavior, CancellationToken cancellationToken)
105+
{
106+
if (m_connectionSettings.ConnectionIdleTimeout == 0)
107+
return;
108+
await CleanPoolAsync(ioBehavior, session => (DateTime.UtcNow - session.LastReturnedUtc).TotalSeconds >= m_connectionSettings.ConnectionIdleTimeout, true, cancellationToken).ConfigureAwait(false);
109+
}
110+
111+
private async Task CleanPoolAsync(IOBehavior ioBehavior, Func<MySqlSession, bool> shouldCleanFn, bool respectMinPoolSize, CancellationToken cancellationToken)
112+
{
113+
// synchronize access to this method as only one clean routine should be run at a time
114+
if (ioBehavior == IOBehavior.Asynchronous)
115+
await m_cleanSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
116+
else
117+
m_cleanSemaphore.Wait(cancellationToken);
118+
119+
try
93120
{
94-
// try to get an open slot; if this fails, connection pool is full and sessions will be disposed when returned to pool
95-
if (ioBehavior == IOBehavior.Asynchronous)
96-
{
97-
if (!await m_sessionSemaphore.WaitAsync(waitTimeout, cancellationToken).ConfigureAwait(false))
98-
return;
99-
}
100-
else
121+
var waitTimeout = TimeSpan.FromMilliseconds(10);
122+
while (true)
101123
{
102-
if (!m_sessionSemaphore.Wait(waitTimeout, cancellationToken))
103-
return;
104-
}
124+
// if respectMinPoolSize is true, return if (leased sessions + waiting sessions <= minPoolSize)
125+
if (respectMinPoolSize)
126+
lock (m_sessions)
127+
if (m_connectionSettings.MaximumPoolSize - m_sessionSemaphore.CurrentCount + m_sessions.Count <= m_connectionSettings.MinimumPoolSize)
128+
return;
129+
130+
// try to get an open slot; if this fails, connection pool is full and sessions will be disposed when returned to pool
131+
if (ioBehavior == IOBehavior.Asynchronous)
132+
{
133+
if (!await m_sessionSemaphore.WaitAsync(waitTimeout, cancellationToken).ConfigureAwait(false))
134+
return;
135+
}
136+
else
137+
{
138+
if (!m_sessionSemaphore.Wait(waitTimeout, cancellationToken))
139+
return;
140+
}
105141

106-
try
107-
{
108-
if (m_sessions.TryDequeue(out var session))
142+
try
109143
{
110-
if (session.PoolGeneration != m_generation)
144+
// check for a waiting session
145+
MySqlSession session = null;
146+
lock (m_sessions)
147+
{
148+
if (m_sessions.Count > 0)
149+
{
150+
session = m_sessions.Last.Value;
151+
m_sessions.RemoveLast();
152+
}
153+
}
154+
if (session == null)
155+
return;
156+
157+
if (shouldCleanFn(session))
111158
{
112-
// session generation does not match pool generation; dispose of it and continue iterating
159+
// session should be cleaned; dispose it and keep iterating
113160
await session.DisposeAsync(ioBehavior, cancellationToken).ConfigureAwait(false);
114-
continue;
115161
}
116162
else
117163
{
118-
// session generation matches pool generation; put it back in the queue and stop iterating
119-
m_sessions.Enqueue(session);
164+
// session should not be cleaned; put it back in the queue and stop iterating
165+
lock (m_sessions)
166+
m_sessions.AddLast(session);
167+
return;
120168
}
121169
}
122-
return;
123-
}
124-
finally
125-
{
126-
m_sessionSemaphore.Release();
170+
finally
171+
{
172+
m_sessionSemaphore.Release();
173+
}
127174
}
128175
}
176+
finally
177+
{
178+
m_cleanSemaphore.Release();
179+
}
129180
}
130181

131182
public static ConnectionPool GetPool(ConnectionSettings cs)
@@ -144,25 +195,51 @@ public static ConnectionPool GetPool(ConnectionSettings cs)
144195

145196
public static async Task ClearPoolsAsync(IOBehavior ioBehavior, CancellationToken cancellationToken)
146197
{
147-
var pools = new List<ConnectionPool>(s_pools.Values);
148-
149-
foreach (var pool in pools)
198+
foreach (var pool in s_pools.Values)
150199
await pool.ClearAsync(ioBehavior, cancellationToken).ConfigureAwait(false);
151200
}
152201

202+
public static async Task ReapPoolsAsync(IOBehavior ioBehavior, CancellationToken cancellationToken)
203+
{
204+
foreach (var pool in s_pools.Values)
205+
await pool.ReapAsync(ioBehavior, cancellationToken).ConfigureAwait(false);
206+
}
207+
153208
private ConnectionPool(ConnectionSettings cs)
154209
{
155210
m_connectionSettings = cs;
156211
m_generation = 0;
212+
m_cleanSemaphore = new SemaphoreSlim(1);
157213
m_sessionSemaphore = new SemaphoreSlim(cs.MaximumPoolSize);
158-
m_sessions = new ConcurrentQueue<MySqlSession>();
214+
m_sessions = new LinkedList<MySqlSession>();
159215
}
160216

161217
static readonly ConcurrentDictionary<string, ConnectionPool> s_pools = new ConcurrentDictionary<string, ConnectionPool>();
218+
#if DEBUG
219+
static readonly TimeSpan ReaperInterval = TimeSpan.FromSeconds(1);
220+
#else
221+
static readonly TimeSpan ReaperInterval = TimeSpan.FromMinutes(1);
222+
#endif
223+
static readonly Task Reaper = Task.Run(async () => {
224+
while (true)
225+
{
226+
var task = Task.Delay(ReaperInterval);
227+
try
228+
{
229+
await ReapPoolsAsync(IOBehavior.Asynchronous, new CancellationTokenSource(ReaperInterval).Token).ConfigureAwait(false);
230+
}
231+
catch
232+
{
233+
// do nothing; we'll try to reap again
234+
}
235+
await task.ConfigureAwait(false);
236+
}
237+
});
162238

163239
int m_generation;
240+
readonly SemaphoreSlim m_cleanSemaphore;
164241
readonly SemaphoreSlim m_sessionSemaphore;
165-
readonly ConcurrentQueue<MySqlSession> m_sessions;
242+
readonly LinkedList<MySqlSession> m_sessions;
166243
readonly ConnectionSettings m_connectionSettings;
167244
}
168245
}

src/MySqlConnector/MySqlClient/MySqlConnectionStringBuilder.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,12 @@ public bool ConnectionReset
8585
set => MySqlConnectionStringOption.ConnectionReset.SetValue(this, value);
8686
}
8787

88+
public uint ConnectionIdleTimeout
89+
{
90+
get => MySqlConnectionStringOption.ConnectionIdleTimeout.GetValue(this);
91+
set => MySqlConnectionStringOption.ConnectionIdleTimeout.SetValue(this, value);
92+
}
93+
8894
public uint MinimumPoolSize
8995
{
9096
get => MySqlConnectionStringOption.MinimumPoolSize.GetValue(this);
@@ -231,6 +237,7 @@ internal abstract class MySqlConnectionStringOption
231237
public static readonly MySqlConnectionStringOption<bool> Pooling;
232238
public static readonly MySqlConnectionStringOption<uint> ConnectionLifeTime;
233239
public static readonly MySqlConnectionStringOption<bool> ConnectionReset;
240+
public static readonly MySqlConnectionStringOption<uint> ConnectionIdleTimeout;
234241
public static readonly MySqlConnectionStringOption<uint> MinimumPoolSize;
235242
public static readonly MySqlConnectionStringOption<uint> MaximumPoolSize;
236243

@@ -321,6 +328,10 @@ static MySqlConnectionStringOption()
321328
keys: new[] { "Connection Reset", "ConnectionReset" },
322329
defaultValue: true));
323330

331+
AddOption(ConnectionIdleTimeout = new MySqlConnectionStringOption<uint>(
332+
keys: new[] { "Connection Idle Timeout", "ConnectionIdleTimeout" },
333+
defaultValue: 180));
334+
324335
AddOption(MinimumPoolSize = new MySqlConnectionStringOption<uint>(
325336
keys: new[] { "Minimum Pool Size", "Min Pool Size", "MinimumPoolSize", "minpoolsize" },
326337
defaultValue: 0));

src/MySqlConnector/MySqlConnector.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@
3636
<PackageReference Include="System.Threading.Tasks.Extensions" Version="4.3.0" />
3737
</ItemGroup>
3838

39+
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
40+
<DefineConstants>DEBUG</DefineConstants>
41+
</PropertyGroup>
42+
3943
<ItemGroup>
4044
<Compile Update="MySqlClient\MySqlCommand.cs">
4145
<SubType>Component</SubType>

src/MySqlConnector/Serialization/ConnectionSettings.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ public ConnectionSettings(MySqlConnectionStringBuilder csb)
3838
Pooling = csb.Pooling;
3939
ConnectionLifeTime = (int)csb.ConnectionLifeTime;
4040
ConnectionReset = csb.ConnectionReset;
41+
ConnectionIdleTimeout = (int)csb.ConnectionIdleTimeout;
4142
if (csb.MinimumPoolSize > csb.MaximumPoolSize)
4243
throw new MySqlException("MaximumPoolSize must be greater than or equal to MinimumPoolSize");
4344
MinimumPoolSize = (int)csb.MinimumPoolSize;
@@ -80,6 +81,7 @@ private ConnectionSettings(ConnectionSettings other, bool? useCompression)
8081
Pooling = other.Pooling;
8182
ConnectionLifeTime = other.ConnectionLifeTime;
8283
ConnectionReset = other.ConnectionReset;
84+
ConnectionIdleTimeout = other.ConnectionIdleTimeout;
8385
MinimumPoolSize = other.MinimumPoolSize;
8486
MaximumPoolSize = other.MaximumPoolSize;
8587

@@ -116,6 +118,7 @@ private ConnectionSettings(ConnectionSettings other, bool? useCompression)
116118
internal readonly bool Pooling;
117119
internal readonly int ConnectionLifeTime;
118120
internal readonly bool ConnectionReset;
121+
internal readonly int ConnectionIdleTimeout;
119122
internal readonly int MinimumPoolSize;
120123
internal readonly int MaximumPoolSize;
121124

@@ -134,4 +137,3 @@ private ConnectionSettings(ConnectionSettings other, bool? useCompression)
134137
}
135138

136139
}
137-

src/MySqlConnector/Serialization/MySqlSession.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,14 @@ public MySqlSession(ConnectionPool pool, int poolGeneration)
3434
public DateTime CreatedUtc { get; }
3535
public ConnectionPool Pool { get; }
3636
public int PoolGeneration { get; }
37+
public DateTime LastReturnedUtc { get; private set; }
3738
public string DatabaseOverride { get; set; }
3839

39-
public void ReturnToPool() => Pool?.Return(this);
40+
public void ReturnToPool()
41+
{
42+
LastReturnedUtc = DateTime.UtcNow;
43+
Pool?.Return(this);
44+
}
4045

4146
public bool IsConnected => m_state == State.Connected;
4247

tests/MySqlConnector.Tests/MySqlConnectionStringBuilderTests.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ public void Defaults()
2525
Assert.Equal("", csb.Database);
2626
#if !BASELINE
2727
Assert.Equal(false, csb.BufferResultSets);
28+
Assert.Equal(180u, csb.ConnectionIdleTimeout);
2829
Assert.Equal(false, csb.ForceSynchronous);
2930
#endif
3031
Assert.Equal(0u, csb.Keepalive);
@@ -64,6 +65,7 @@ public void ParseConnectionString()
6465
"ConnectionReset=false;" +
6566
"Convert Zero Datetime=true;" +
6667
#if !BASELINE
68+
"connectionidletimeout=30;" +
6769
"bufferresultsets=true;" +
6870
"forcesynchronous=true;" +
6971
#endif
@@ -91,6 +93,7 @@ public void ParseConnectionString()
9193
Assert.Equal("schema_name", csb.Database);
9294
#if !BASELINE
9395
Assert.Equal(true, csb.BufferResultSets);
96+
Assert.Equal(30u, csb.ConnectionIdleTimeout);
9497
Assert.Equal(true, csb.ForceSynchronous);
9598
#endif
9699
Assert.Equal(90u, csb.Keepalive);

0 commit comments

Comments
 (0)