Skip to content

Commit 660bf82

Browse files
committed
Merge dns-check-interval into master.
2 parents 305c621 + c683cb2 commit 660bf82

File tree

6 files changed

+194
-14
lines changed

6 files changed

+194
-14
lines changed

docs/content/connection-options.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,15 @@ Connection pooling is enabled by default. These options are used to configure it
232232
<td>100</td>
233233
<td>The maximum number of connections allowed in the pool.</td>
234234
</tr>
235+
<tr id="DnsCheckInterval">
236+
<td>DNS Check Interval, DnsCheckInterval</td>
237+
<td>0</td>
238+
<td>The number of seconds between checks for DNS changes, or 0 to disable periodic checks.
239+
If the periodic check determines that one of the <code>Server</code> hostnames resolves to a different IP address, the pool will be cleared.
240+
This is useful in HA scenarios where failover is accomplished by changing the IP address to which a hostname resolves.
241+
Existing connections in the pool may have valid TCP connections to a server that is no longer responding or has been marked readonly;
242+
clearing the pool (when DNS changes) forces all these existing connections to be reestablished.</td>
243+
</tr>
235244
</table>
236245

237246
## Other Options

src/MySqlConnector/Core/ConnectionPool.cs

Lines changed: 156 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
using System.Collections.Concurrent;
2+
using System.Diagnostics;
3+
using System.Net;
24
using System.Security.Authentication;
35
using MySqlConnector.Logging;
46
using MySqlConnector.Protocol.Serialization;
57
using MySqlConnector.Utilities;
68

79
namespace MySqlConnector.Core;
810

9-
internal sealed class ConnectionPool
11+
internal sealed class ConnectionPool : IDisposable
1012
{
1113
public int Id { get; }
1214

@@ -219,6 +221,30 @@ public async Task ReapAsync(IOBehavior ioBehavior, CancellationToken cancellatio
219221
return procedureCache;
220222
}
221223

224+
public void Dispose()
225+
{
226+
Log.Debug("Pool{0} disposing connection pool", m_logArguments);
227+
#if NET6_0_OR_GREATER
228+
m_dnsCheckTimer?.Dispose();
229+
m_dnsCheckTimer = null;
230+
m_reaperTimer?.Dispose();
231+
m_reaperTimer = null;
232+
#else
233+
if (m_dnsCheckTimer is not null)
234+
{
235+
using var dnsCheckWaitHandle = new ManualResetEvent(false);
236+
m_dnsCheckTimer.Dispose(dnsCheckWaitHandle);
237+
dnsCheckWaitHandle.WaitOne();
238+
}
239+
if (m_reaperTimer is not null)
240+
{
241+
using var reaperWaitHandle = new ManualResetEvent(false);
242+
m_reaperTimer.Dispose(reaperWaitHandle);
243+
reaperWaitHandle.WaitOne();
244+
}
245+
#endif
246+
}
247+
222248
/// <summary>
223249
/// Examines all the <see cref="ServerSession"/> objects in <see cref="m_leasedSessions"/> to determine if any
224250
/// have an owning <see cref="MySqlConnection"/> that has been garbage-collected. If so, assumes that the connection
@@ -438,6 +464,7 @@ private async ValueTask<ServerSession> ConnectSessionAsync(MySqlConnection conne
438464
var connectionSettings = new ConnectionSettings(connectionStringBuilder);
439465
var pool = new ConnectionPool(connectionSettings);
440466
pool.StartReaperTask();
467+
pool.StartDnsCheckTimer();
441468
return pool;
442469
}
443470

@@ -485,6 +512,7 @@ private async ValueTask<ServerSession> ConnectSessionAsync(MySqlConnection conne
485512
{
486513
s_mruCache = new(connectionString, pool);
487514
pool.StartReaperTask();
515+
pool.StartDnsCheckTimer();
488516

489517
// if we won the race to create the new pool, also store it under the original connection string
490518
if (connectionString != normalizedConnectionString)
@@ -546,27 +574,136 @@ private ConnectionPool(ConnectionSettings cs)
546574

547575
private void StartReaperTask()
548576
{
549-
if (ConnectionSettings.ConnectionIdleTimeout > 0)
577+
if (ConnectionSettings.ConnectionIdleTimeout <= 0)
578+
return;
579+
580+
var reaperInterval = TimeSpan.FromSeconds(Math.Max(1, Math.Min(60, ConnectionSettings.ConnectionIdleTimeout / 2)));
581+
582+
#if NET6_0_OR_GREATER
583+
m_reaperTimer = new PeriodicTimer(reaperInterval);
584+
_ = RunTimer();
585+
586+
async Task RunTimer()
587+
{
588+
while (await m_reaperTimer.WaitForNextTickAsync().ConfigureAwait(false))
589+
{
590+
try
591+
{
592+
using var source = new CancellationTokenSource(reaperInterval);
593+
await ReapAsync(IOBehavior.Asynchronous, source.Token).ConfigureAwait(false);
594+
}
595+
catch
596+
{
597+
// do nothing; we'll try to reap again
598+
}
599+
}
600+
}
601+
#else
602+
m_reaperTimer = new Timer(t =>
603+
{
604+
var stopwatch = Stopwatch.StartNew();
605+
try
606+
{
607+
using var source = new CancellationTokenSource(reaperInterval);
608+
ReapAsync(IOBehavior.Synchronous, source.Token).GetAwaiter().GetResult();
609+
}
610+
catch
611+
{
612+
// do nothing; we'll try to reap again
613+
}
614+
615+
// restart the timer, accounting for the time spent reaping
616+
var delay = reaperInterval - stopwatch.Elapsed;
617+
((Timer) t!).Change(delay < TimeSpan.Zero ? TimeSpan.Zero : delay, TimeSpan.FromMilliseconds(-1));
618+
});
619+
m_reaperTimer.Change(reaperInterval, TimeSpan.FromMilliseconds(-1));
620+
#endif
621+
}
622+
623+
private void StartDnsCheckTimer()
624+
{
625+
if (ConnectionSettings.ConnectionProtocol != MySqlConnectionProtocol.Tcp || ConnectionSettings.DnsCheckInterval <= 0)
626+
return;
627+
628+
var hostNames = ConnectionSettings.HostNames!;
629+
var hostAddresses = new IPAddress[hostNames.Count][];
630+
631+
#if NET6_0_OR_GREATER
632+
m_dnsCheckTimer = new PeriodicTimer(TimeSpan.FromSeconds(ConnectionSettings.DnsCheckInterval));
633+
_ = RunTimer();
634+
635+
async Task RunTimer()
550636
{
551-
var reaperInterval = TimeSpan.FromSeconds(Math.Max(1, Math.Min(60, ConnectionSettings.ConnectionIdleTimeout / 2)));
552-
m_reaperTask = Task.Run(async () =>
637+
while (await m_dnsCheckTimer.WaitForNextTickAsync().ConfigureAwait(false))
553638
{
554-
while (true)
639+
Log.Trace("Pool{0} checking for DNS changes", m_logArguments);
640+
var hostNamesChanged = false;
641+
for (var hostNameIndex = 0; hostNameIndex < hostNames.Count; hostNameIndex++)
555642
{
556-
var task = Task.Delay(reaperInterval);
557643
try
558644
{
559-
using var source = new CancellationTokenSource(reaperInterval);
560-
await ReapAsync(IOBehavior.Asynchronous, source.Token).ConfigureAwait(false);
645+
var ipAddresses = await Dns.GetHostAddressesAsync(hostNames[hostNameIndex]).ConfigureAwait(false);
646+
if (hostAddresses[hostNameIndex] is null)
647+
{
648+
hostAddresses[hostNameIndex] = ipAddresses;
649+
}
650+
else if (hostAddresses[hostNameIndex].Except(ipAddresses).Any())
651+
{
652+
Log.Debug("Pool{0} detected DNS change for HostName '{1}': {2} to {3}", m_logArguments[0], hostNames[hostNameIndex], string.Join<IPAddress>(',', hostAddresses[hostNameIndex]), string.Join<IPAddress>(',', ipAddresses));
653+
hostAddresses[hostNameIndex] = ipAddresses;
654+
hostNamesChanged = true;
655+
}
561656
}
562-
catch
657+
catch (Exception ex)
563658
{
564-
// do nothing; we'll try to reap again
659+
// do nothing; we'll try again later
660+
Log.Debug("Pool{0} DNS check failed; ignoring HostName '{1}': {2}", m_logArguments[0], hostNames[hostNameIndex], ex.Message);
565661
}
566-
await task.ConfigureAwait(false);
567662
}
568-
});
663+
if (hostNamesChanged)
664+
{
665+
Log.Info("Pool{0} clearing pool due to DNS changes", m_logArguments);
666+
await ClearAsync(IOBehavior.Asynchronous, CancellationToken.None).ConfigureAwait(false);
667+
}
668+
}
569669
}
670+
#else
671+
var interval = Math.Min(int.MaxValue / 1000, ConnectionSettings.DnsCheckInterval) * 1000;
672+
m_dnsCheckTimer = new Timer(t =>
673+
{
674+
Log.Trace("Pool{0} checking for DNS changes", m_logArguments);
675+
var hostNamesChanged = false;
676+
for (var hostNameIndex = 0; hostNameIndex < hostNames.Count; hostNameIndex++)
677+
{
678+
try
679+
{
680+
var ipAddresses = Dns.GetHostAddresses(hostNames[hostNameIndex]);
681+
if (hostAddresses[hostNameIndex] is null)
682+
{
683+
hostAddresses[hostNameIndex] = ipAddresses;
684+
}
685+
else if (hostAddresses[hostNameIndex].Except(ipAddresses).Any())
686+
{
687+
Log.Debug("Pool{0} detected DNS change for HostName '{1}': {2} to {3}", m_logArguments[0], hostNames[hostNameIndex], string.Join<IPAddress>(",", hostAddresses[hostNameIndex]), string.Join<IPAddress>(",", ipAddresses));
688+
hostAddresses[hostNameIndex] = ipAddresses;
689+
hostNamesChanged = true;
690+
}
691+
}
692+
catch (Exception ex)
693+
{
694+
// do nothing; we'll try again later
695+
Log.Debug("Pool{0} DNS check failed; ignoring HostName '{1}': {2}", m_logArguments[0], hostNames[hostNameIndex], ex.Message);
696+
}
697+
}
698+
if (hostNamesChanged)
699+
{
700+
Log.Info("Pool{0} clearing pool due to DNS changes", m_logArguments);
701+
ClearAsync(IOBehavior.Synchronous, CancellationToken.None).GetAwaiter().GetResult();
702+
}
703+
((Timer) t!).Change(interval, -1);
704+
});
705+
m_dnsCheckTimer.Change(interval, -1);
706+
#endif
570707
}
571708

572709
private void AdjustHostConnectionCount(ServerSession session, int delta)
@@ -626,8 +763,14 @@ private static void OnAppDomainShutDown(object? sender, EventArgs e) =>
626763
private readonly Dictionary<string, int>? m_hostSessions;
627764
private readonly object[] m_logArguments;
628765
private int m_generation;
629-
private Task? m_reaperTask;
630766
private uint m_lastRecoveryTime;
631767
private int m_lastSessionId;
632768
private Dictionary<string, CachedProcedure?>? m_procedureCache;
769+
#if NET6_0_OR_GREATER
770+
private PeriodicTimer? m_dnsCheckTimer;
771+
private PeriodicTimer? m_reaperTimer;
772+
#else
773+
private Timer? m_dnsCheckTimer;
774+
private Timer? m_reaperTimer;
775+
#endif
633776
}

src/MySqlConnector/Core/ConnectionSettings.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ public ConnectionSettings(MySqlConnectionStringBuilder csb)
119119
throw new MySqlException("MaximumPoolSize must be greater than or equal to MinimumPoolSize");
120120
MinimumPoolSize = ToSigned(csb.MinimumPoolSize);
121121
MaximumPoolSize = ToSigned(csb.MaximumPoolSize);
122+
DnsCheckInterval = ToSigned(csb.DnsCheckInterval);
122123

123124
// Other Options
124125
AllowLoadLocalInfile = csb.AllowLoadLocalInfile;
@@ -213,6 +214,7 @@ private static MySqlGuidFormat GetEffectiveGuidFormat(MySqlGuidFormat guidFormat
213214
public int ConnectionIdleTimeout { get; }
214215
public int MinimumPoolSize { get; }
215216
public int MaximumPoolSize { get; }
217+
public int DnsCheckInterval { get; }
216218

217219
// Other Options
218220
public bool AllowLoadLocalInfile { get; }
@@ -299,6 +301,7 @@ private ConnectionSettings(ConnectionSettings other, string host, int port, stri
299301
ConnectionIdleTimeout = other.ConnectionIdleTimeout;
300302
MinimumPoolSize = other.MinimumPoolSize;
301303
MaximumPoolSize = other.MaximumPoolSize;
304+
DnsCheckInterval = other.DnsCheckInterval;
302305

303306
AllowLoadLocalInfile = other.AllowLoadLocalInfile;
304307
AllowPublicKeyRetrieval = other.AllowPublicKeyRetrieval;

src/MySqlConnector/MySqlConnectionStringBuilder.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,19 @@ public uint MaximumPoolSize
404404
set => MySqlConnectionStringOption.MaximumPoolSize.SetValue(this, value);
405405
}
406406

407+
/// <summary>
408+
/// The number of seconds between checks for DNS changes, or 0 to disable periodic checks.
409+
/// </summary>
410+
[Category("Pooling")]
411+
[DefaultValue(0u)]
412+
[Description("The number of seconds between checks for DNS changes.")]
413+
[DisplayName("DNS Check Interval")]
414+
public uint DnsCheckInterval
415+
{
416+
get => MySqlConnectionStringOption.DnsCheckInterval.GetValue(this);
417+
set => MySqlConnectionStringOption.DnsCheckInterval.SetValue(this, value);
418+
}
419+
407420
// Other Options
408421

409422
/// <summary>
@@ -915,6 +928,7 @@ internal abstract partial class MySqlConnectionStringOption
915928
public static readonly MySqlConnectionStringValueOption<uint> ConnectionIdleTimeout;
916929
public static readonly MySqlConnectionStringValueOption<uint> MinimumPoolSize;
917930
public static readonly MySqlConnectionStringValueOption<uint> MaximumPoolSize;
931+
public static readonly MySqlConnectionStringValueOption<uint> DnsCheckInterval;
918932

919933
// Other Options
920934
public static readonly MySqlConnectionStringValueOption<bool> AllowLoadLocalInfile;
@@ -1118,6 +1132,10 @@ static MySqlConnectionStringOption()
11181132
keys: new[] { "Maximum Pool Size", "Max Pool Size", "MaximumPoolSize", "maxpoolsize" },
11191133
defaultValue: 100u));
11201134

1135+
AddOption(DnsCheckInterval = new(
1136+
keys: new[] { "DNS Check Interval", "DnsCheckInterval" },
1137+
defaultValue: 0u));
1138+
11211139
// Other Options
11221140
AddOption(AllowLoadLocalInfile = new(
11231141
keys: new[] { "Allow Load Local Infile", "AllowLoadLocalInfile" },

src/MySqlConnector/MySqlDataSource.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,10 @@ protected override void Dispose(bool disposing)
9191
private async ValueTask DisposeAsync(IOBehavior ioBehavior)
9292
{
9393
if (Pool is not null)
94+
{
9495
await Pool.ClearAsync(ioBehavior, default).ConfigureAwait(false);
96+
Pool.Dispose();
97+
}
9598
m_isDisposed = true;
9699
}
97100

tests/MySqlConnector.Tests/MySqlConnectionStringBuilderTests.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ public void ParseConnectionString()
144144
"use xa transactions=false;" +
145145
"tls cipher suites=TLS_AES_128_CCM_8_SHA256,TLS_RSA_WITH_RC4_128_MD5;" +
146146
"ignore prepare=true;" +
147+
"dnscheckinterval=15;" +
147148
#endif
148149
"interactive=true;" +
149150
"Keep Alive=90;" +
@@ -207,6 +208,7 @@ public void ParseConnectionString()
207208
Assert.False(csb.UseXaTransactions);
208209
Assert.Equal("TLS_AES_128_CCM_8_SHA256,TLS_RSA_WITH_RC4_128_MD5", csb.TlsCipherSuites);
209210
Assert.True(csb.IgnorePrepare);
211+
Assert.Equal(15u, csb.DnsCheckInterval);
210212
#endif
211213
Assert.True(csb.InteractiveSession);
212214
Assert.Equal(90u, csb.Keepalive);
@@ -239,7 +241,8 @@ public void ParseConnectionString()
239241
"Certificate Store Location=CurrentUser;Certificate Thumbprint=thumbprint123;SSL Cert=client-cert.pem;SSL Key=client-key.pem;" +
240242
"SSL CA=ca.pem;TLS Version=\"TLS 1.2, TLS 1.3\";TLS Cipher Suites=TLS_AES_128_CCM_8_SHA256,TLS_RSA_WITH_RC4_128_MD5;" +
241243
"Pooling=False;Connection Lifetime=15;Connection Reset=False;Defer Connection Reset=True;Connection Idle Timeout=30;" +
242-
"Minimum Pool Size=5;Maximum Pool Size=15;Allow Load Local Infile=True;Allow Public Key Retrieval=True;Allow User Variables=True;" +
244+
"Minimum Pool Size=5;Maximum Pool Size=15;DNS Check Interval=15;" +
245+
"Allow Load Local Infile=True;Allow Public Key Retrieval=True;Allow User Variables=True;" +
243246
"Allow Zero DateTime=True;Application Name=\"My Test Application\";Auto Enlist=False;Cancellation Timeout=-1;Character Set=latin1;" +
244247
"Connection Timeout=30;Convert Zero DateTime=True;DateTime Kind=Utc;Default Command Timeout=123;Force Synchronous=True;" +
245248
"GUID Format=TimeSwapBinary16;Ignore Command Transaction=True;Ignore Prepare=True;Interactive Session=True;Keep Alive=90;" +
@@ -525,6 +528,7 @@ public void ParseInvalidTlsVersion()
525528
[InlineData("Cancellation Timeout", 5)]
526529
[InlineData("Connection Idle Timeout", 10u)]
527530
[InlineData("DateTime Kind", MySqlDateTimeKind.Utc)]
531+
[InlineData("DNS Check Interval", 15u)]
528532
[InlineData("Force Synchronous", true)]
529533
[InlineData("GUID Format", MySqlGuidFormat.Binary16)]
530534
[InlineData("Ignore Command Transaction", true)]

0 commit comments

Comments
 (0)