From 5be2f5554ee7e37c854fd5659579d59c53d58fd4 Mon Sep 17 00:00:00 2001 From: Bradley Grainger Date: Sun, 10 Dec 2023 23:18:25 -0800 Subject: [PATCH] Use MySqlDataSource in `AddMySql` by default (#2096) * Send a MySQL ping packet by default. Fixes #2031 Users can opt in to setting a command that will be executed on the server, but the default is now a more efficient ping. * Add AddMySql overload for MySqlDataSource. This pushes users towards good defaults: firstly registering a MySqlDataSource as a singleton in DI, then just calling .AddHealthChecks().AddMySql() with no other arguments to retrieve and use that same data source for health checks. * Add package README for HealthChecks.MySql. * Use new MySqlHealthCheckOptions API. There are two constructors: one to initialise with a MySqlDataSource, and one with a connection string. This helps enforce correct uasge of the API by ensuring that exactly one is set. --- .../MySqlHealthCheckBuilderExtensions.cs | 67 ++++++++++++++----- src/HealthChecks.MySql/MySqlHealthCheck.cs | 33 ++++++--- .../MySqlHealthCheckOptions.cs | 48 +++++++++++-- src/HealthChecks.MySql/README.md | 59 ++++++++++++++++ .../DependencyInjection/RegistrationTests.cs | 21 ++++++ .../Functional/MySqlHealthCheckTests.cs | 35 ++++++++-- .../HealthChecks.MySql.Tests.csproj | 4 ++ .../HealthChecks.MySql.approved.txt | 13 ++-- 8 files changed, 241 insertions(+), 39 deletions(-) create mode 100644 src/HealthChecks.MySql/README.md diff --git a/src/HealthChecks.MySql/DependencyInjection/MySqlHealthCheckBuilderExtensions.cs b/src/HealthChecks.MySql/DependencyInjection/MySqlHealthCheckBuilderExtensions.cs index 09e2fac106..fc8fcf0fdc 100644 --- a/src/HealthChecks.MySql/DependencyInjection/MySqlHealthCheckBuilderExtensions.cs +++ b/src/HealthChecks.MySql/DependencyInjection/MySqlHealthCheckBuilderExtensions.cs @@ -10,14 +10,50 @@ namespace Microsoft.Extensions.DependencyInjection; public static class MySqlHealthCheckBuilderExtensions { private const string NAME = "mysql"; - internal const string HEALTH_QUERY = "SELECT 1;"; + + /// + /// Add a health check for MySQL databases. + /// + /// The . + /// An optional factory to create the . By default, one will be retrieved from the service collection. + /// The optional query to be executed. If this is null, a MySQL "ping" packet will be sent to the server instead of a query. + /// An optional action to allow additional MySQL specific configuration. + /// The health check name. Optional. If null the type name 'mysql' will be used for the name. + /// + /// The that should be reported when the health check fails. Optional. If null then + /// the default status of will be reported. + /// + /// A list of tags that can be used to filter sets of health checks. Optional. + /// An optional representing the timeout of the check. + /// The specified . + public static IHealthChecksBuilder AddMySql( + this IHealthChecksBuilder builder, + Func? dataSourceFactory = null, + string? healthQuery = null, + Action? configure = null, + string? name = default, + HealthStatus? failureStatus = default, + IEnumerable? tags = default, + TimeSpan? timeout = default) + { + return builder.Add(new HealthCheckRegistration( + name ?? NAME, + sp => new MySqlHealthCheck(new(dataSourceFactory?.Invoke(sp) ?? sp.GetRequiredService()) + { + CommandText = healthQuery, + Configure = configure, + }), + failureStatus, + tags, + timeout)); + } /// /// Add a health check for MySQL databases. /// /// The . /// The MySQL connection string to be used. - /// The query to be executed. + /// The optional query to be executed. If this is null, a MySQL "ping" packet will be sent to the server instead of a query. /// An optional action to allow additional MySQL specific configuration. /// The health check name. Optional. If null the type name 'mysql' will be used for the name. /// @@ -30,14 +66,20 @@ public static class MySqlHealthCheckBuilderExtensions public static IHealthChecksBuilder AddMySql( this IHealthChecksBuilder builder, string connectionString, - string healthQuery = HEALTH_QUERY, + string? healthQuery = null, Action? configure = null, string? name = default, HealthStatus? failureStatus = default, IEnumerable? tags = default, TimeSpan? timeout = default) { - return builder.AddMySql(_ => connectionString, healthQuery, configure, name, failureStatus, tags, timeout); + Guard.ThrowIfNull(connectionString, throwOnEmptyString: true); + + return builder.AddMySql(new(connectionString) + { + CommandText = healthQuery, + Configure = configure + }, name, failureStatus, tags, timeout); } /// @@ -45,7 +87,7 @@ public static IHealthChecksBuilder AddMySql( /// /// The . /// A factory to build the MySQL connection string to use. - /// The query to be executed. + /// The optional query to be executed. If this is null, a MySQL "ping" packet will be sent to the server instead of a query. /// An optional action to allow additional MySQL specific configuration. /// The health check name. Optional. If null the type name 'mysql' will be used for the name. /// @@ -58,7 +100,7 @@ public static IHealthChecksBuilder AddMySql( public static IHealthChecksBuilder AddMySql( this IHealthChecksBuilder builder, Func connectionStringFactory, - string healthQuery = HEALTH_QUERY, + string? healthQuery = null, Action? configure = null, string? name = default, HealthStatus? failureStatus = default, @@ -69,16 +111,11 @@ public static IHealthChecksBuilder AddMySql( return builder.Add(new HealthCheckRegistration( name ?? NAME, - sp => + sp => new MySqlHealthCheck(new(connectionStringFactory(sp)) { - var options = new MySqlHealthCheckOptions - { - ConnectionString = connectionStringFactory(sp), - CommandText = healthQuery, - Configure = configure, - }; - return new MySqlHealthCheck(options); - }, + CommandText = healthQuery, + Configure = configure, + }), failureStatus, tags, timeout)); diff --git a/src/HealthChecks.MySql/MySqlHealthCheck.cs b/src/HealthChecks.MySql/MySqlHealthCheck.cs index fb7f433193..e12009cb2d 100644 --- a/src/HealthChecks.MySql/MySqlHealthCheck.cs +++ b/src/HealthChecks.MySql/MySqlHealthCheck.cs @@ -12,8 +12,11 @@ public class MySqlHealthCheck : IHealthCheck public MySqlHealthCheck(MySqlHealthCheckOptions options) { - Guard.ThrowIfNull(options.ConnectionString, true); - Guard.ThrowIfNull(options.CommandText, true); + Guard.ThrowIfNull(options); + if (options.DataSource is null && options.ConnectionString is null) + throw new InvalidOperationException("One of options.DataSource or options.ConnectionString must be specified."); + if (options.DataSource is not null && options.ConnectionString is not null) + throw new InvalidOperationException("Only one of options.DataSource or options.ConnectionString must be specified."); _options = options; } @@ -22,18 +25,30 @@ public async Task CheckHealthAsync(HealthCheckContext context { try { - using var connection = new MySqlConnection(_options.ConnectionString); + using var connection = _options.DataSource is not null ? + _options.DataSource.CreateConnection() : + new MySqlConnection(_options.ConnectionString); _options.Configure?.Invoke(connection); await connection.OpenAsync(cancellationToken).ConfigureAwait(false); - using var command = connection.CreateCommand(); - command.CommandText = _options.CommandText; - object? result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + if (_options.CommandText is { } commandText) + { + using var command = connection.CreateCommand(); + command.CommandText = _options.CommandText; + object? result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); - return _options.HealthCheckResultBuilder == null - ? HealthCheckResult.Healthy() - : _options.HealthCheckResultBuilder(result); + return _options.HealthCheckResultBuilder == null + ? HealthCheckResult.Healthy() + : _options.HealthCheckResultBuilder(result); + } + else + { + var success = await connection.PingAsync(cancellationToken).ConfigureAwait(false); + return _options.HealthCheckResultBuilder is null + ? (success ? HealthCheckResult.Healthy() : new HealthCheckResult(context.Registration.FailureStatus)) : + _options.HealthCheckResultBuilder(success); + } } catch (Exception ex) { diff --git a/src/HealthChecks.MySql/MySqlHealthCheckOptions.cs b/src/HealthChecks.MySql/MySqlHealthCheckOptions.cs index d8a1f073d7..a2a15690af 100644 --- a/src/HealthChecks.MySql/MySqlHealthCheckOptions.cs +++ b/src/HealthChecks.MySql/MySqlHealthCheckOptions.cs @@ -1,4 +1,3 @@ -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using MySqlConnector; @@ -8,16 +7,55 @@ namespace HealthChecks.MySql; /// Options for . /// public class MySqlHealthCheckOptions -{ +{ + /// + /// Creates an instance of . + /// + /// The to be used. + /// + /// Depending on how the was configured, the connections it hands out may be pooled. + /// That is why it should be the exact same that is used by other parts of your app. + /// + public MySqlHealthCheckOptions(MySqlDataSource dataSource) + { + DataSource = Guard.ThrowIfNull(dataSource); + } + + /// + /// Creates an instance of . + /// + /// The MySQL connection string to be used. + /// + /// supports additional configuration beyond the connection string, such as logging and naming pools for diagnostics. + /// To specify a data source, use and the constructor. + /// + public MySqlHealthCheckOptions(string connectionString) + { + ConnectionString = Guard.ThrowIfNull(connectionString, throwOnEmptyString: true); + } + + /// + /// The MySQL data source to be used. + /// + /// + /// Depending on how the was configured, the connections it hands out may be pooled. + /// That is why it should be the exact same that is used by other parts of your app. + /// + public MySqlDataSource? DataSource { get; } + /// - /// The MySQL connection string to be used. + /// The MySQL connection string to be used, if isn't set. /// - public string ConnectionString { get; set; } = null!; + /// + /// supports additional configuration beyond the connection string, such as logging and naming pools for diagnostics. + /// To specify a data source, use and the constructor. + /// + public string? ConnectionString { get; } /// /// The query to be executed. /// - public string CommandText { get; set; } = MySqlHealthCheckBuilderExtensions.HEALTH_QUERY; + public string? CommandText { get; set; } /// /// An optional action executed before the connection is opened in the health check. diff --git a/src/HealthChecks.MySql/README.md b/src/HealthChecks.MySql/README.md new file mode 100644 index 0000000000..6ec4a6db88 --- /dev/null +++ b/src/HealthChecks.MySql/README.md @@ -0,0 +1,59 @@ +## MySQL Health Check + +This health check verifies the ability to communicate with a MySQL Server. +It uses the provided [MySqlDataSource](https://mysqlconnector.net/api/mysqlconnector/mysqldatasourcetype/) or a connection string to connect to the server. + +### Defaults + +By default, the `MySqlDataSource` instance is resolved from service provider. +(This should be the same as the instance being used by the application; do not create a new `MySqlDataSource` just for the health check.) +The health check will send a MySQL "ping" packet to the server to verify connectivity. + +```csharp +builder.Services + .AddMySqlDataSource(builder.Configuration.GetConnectionString("mysql")) // using the MySqlConnector.DependencyInjection package + .AddHealthChecks().AddMySql(); +``` + +### Connection String + +You can also specify a connection string directly: + +```csharp +builder.Services.AddHealthChecks().AddMySql(connectionString: "Server=...;User Id=...;Password=..."); +``` + +This can be useful if you're not using `MySqlDataSource` in your application. + +### Customization + +You can additionally add the following parameters: + +- `healthQuery`: A query to run against the server. If `null` (the default), the health check will send a MySQL "ping" packet to the server. +- `configure`: An action to configure the `MySqlConnection` object. This is called after the `MySqlConnection` is created but before the connection is opened. +- `name`: The health check name. The default is `mysql`. +- `failureStatus`: The `HealthStatus` that should be reported when the health check fails. Default is `HealthStatus.Unhealthy`. +- `tags`: A list of tags that can be used to filter sets of health checks. +- `timeout`: A `System.TimeSpan` representing the timeout of the check. + +```csharp +builder.Services + .AddMySqlDataSource(builder.Configuration.GetConnectionString("mysql")) + .AddHealthChecks().AddMySql( + healthQuery: "SELECT 1;", + configure: conn => conn.ConnectTimeout = 3, + name: "MySQL" + ); +``` + +### Breaking changes + +In previous versions, `MySqlHealthCheck` defaulted to testing connectivity by sending a `SELECT 1;` query to the server. +It has been changed to send a more efficient "ping" packet instead. +To restore the previous behavior, specify `healthQuery: "SELECT 1;"` when registering the health check. + +While not a breaking change, it's now preferred to use `MySqlDataSource` instead of a connection string. +This allows the health check to use the same connection pool as the rest of the application. +This can be achieved by calling the `.AddMySql()` overload that has no required parameters. +The health check assumes that a `MySqlDataSource` instance has been registered with the service provider and will retrieve it automatically. + diff --git a/test/HealthChecks.MySql.Tests/DependencyInjection/RegistrationTests.cs b/test/HealthChecks.MySql.Tests/DependencyInjection/RegistrationTests.cs index 9f3a030da7..15a1627c67 100644 --- a/test/HealthChecks.MySql.Tests/DependencyInjection/RegistrationTests.cs +++ b/test/HealthChecks.MySql.Tests/DependencyInjection/RegistrationTests.cs @@ -1,3 +1,5 @@ +using MySqlConnector; + namespace HealthChecks.MySql.Tests.DependencyInjection; public class mysql_registration_should @@ -18,6 +20,7 @@ public void add_health_check_when_properly_configured() registration.Name.ShouldBe("mysql"); check.ShouldBeOfType(); } + [Fact] public void add_named_health_check_when_properly_configured() { @@ -34,4 +37,22 @@ public void add_named_health_check_when_properly_configured() registration.Name.ShouldBe("my-mysql-group"); check.ShouldBeOfType(); } + + [Fact] + public void add_health_check_for_data_source() + { + var services = new ServiceCollection(); + services + .AddMySqlDataSource("Server=example") + .AddHealthChecks().AddMySql(); + + using var serviceProvider = services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>(); + + var registration = options.Value.Registrations.First(); + var check = registration.Factory(serviceProvider); + + registration.Name.ShouldBe("mysql"); + check.ShouldBeOfType(); + } } diff --git a/test/HealthChecks.MySql.Tests/Functional/MySqlHealthCheckTests.cs b/test/HealthChecks.MySql.Tests/Functional/MySqlHealthCheckTests.cs index 802b54f85c..affb98e552 100644 --- a/test/HealthChecks.MySql.Tests/Functional/MySqlHealthCheckTests.cs +++ b/test/HealthChecks.MySql.Tests/Functional/MySqlHealthCheckTests.cs @@ -1,11 +1,39 @@ using System.Net; +using MySqlConnector; namespace HealthChecks.MySql.Tests.Functional; public class mysql_healthcheck_should { [Fact] - public async Task be_healthy_when_mysql_server_is_available() + public async Task be_healthy_when_mysql_server_is_available_using_data_source() + { + var connectionString = "server=localhost;port=3306;database=information_schema;uid=root;password=Password12!"; + + var webHostBuilder = new WebHostBuilder() + .ConfigureServices(services => + { + services + .AddMySqlDataSource(connectionString) + .AddHealthChecks().AddMySql(tags: new string[] { "mysql" }); + }) + .Configure(app => + { + app.UseHealthChecks("/health", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("mysql") + }); + }); + + using var server = new TestServer(webHostBuilder); + + using var response = await server.CreateRequest("/health").GetAsync(); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + } + + [Fact] + public async Task be_healthy_when_mysql_server_is_available_using_connection_string() { var connectionString = "server=localhost;port=3306;database=information_schema;uid=root;password=Password12!"; @@ -64,10 +92,7 @@ public async Task be_unhealthy_when_mysql_server_is_unavailable_using_options() var webHostBuilder = new WebHostBuilder() .ConfigureServices(services => { - var mysqlOptions = new MySqlHealthCheckOptions - { - ConnectionString = connectionString - }; + var mysqlOptions = new MySqlHealthCheckOptions(connectionString); services.AddHealthChecks() .AddMySql(mysqlOptions, tags: new string[] { "mysql" }); }) diff --git a/test/HealthChecks.MySql.Tests/HealthChecks.MySql.Tests.csproj b/test/HealthChecks.MySql.Tests/HealthChecks.MySql.Tests.csproj index 5bd9b8276d..b4b45abd6d 100644 --- a/test/HealthChecks.MySql.Tests/HealthChecks.MySql.Tests.csproj +++ b/test/HealthChecks.MySql.Tests/HealthChecks.MySql.Tests.csproj @@ -1,5 +1,9 @@ + + + + diff --git a/test/HealthChecks.MySql.Tests/HealthChecks.MySql.approved.txt b/test/HealthChecks.MySql.Tests/HealthChecks.MySql.approved.txt index f3dfe4a8d5..4dc33b35cd 100644 --- a/test/HealthChecks.MySql.Tests/HealthChecks.MySql.approved.txt +++ b/test/HealthChecks.MySql.Tests/HealthChecks.MySql.approved.txt @@ -7,10 +7,12 @@ namespace HealthChecks.MySql } public class MySqlHealthCheckOptions { - public MySqlHealthCheckOptions() { } - public string CommandText { get; set; } + public MySqlHealthCheckOptions(MySqlConnector.MySqlDataSource dataSource) { } + public MySqlHealthCheckOptions(string connectionString) { } + public string? CommandText { get; set; } public System.Action? Configure { get; set; } - public string ConnectionString { get; set; } + public string? ConnectionString { get; } + public MySqlConnector.MySqlDataSource? DataSource { get; } public System.Func? HealthCheckResultBuilder { get; set; } } } @@ -19,7 +21,8 @@ namespace Microsoft.Extensions.DependencyInjection public static class MySqlHealthCheckBuilderExtensions { public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddMySql(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, HealthChecks.MySql.MySqlHealthCheckOptions options, string? name = null, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus = default, System.Collections.Generic.IEnumerable? tags = null, System.TimeSpan? timeout = default) { } - public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddMySql(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, System.Func connectionStringFactory, string healthQuery = "SELECT 1;", System.Action? configure = null, string? name = null, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus = default, System.Collections.Generic.IEnumerable? tags = null, System.TimeSpan? timeout = default) { } - public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddMySql(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string connectionString, string healthQuery = "SELECT 1;", System.Action? configure = null, string? name = null, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus = default, System.Collections.Generic.IEnumerable? tags = null, System.TimeSpan? timeout = default) { } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddMySql(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, System.Func connectionStringFactory, string? healthQuery = null, System.Action? configure = null, string? name = null, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus = default, System.Collections.Generic.IEnumerable? tags = null, System.TimeSpan? timeout = default) { } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddMySql(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, System.Func? dataSourceFactory = null, string? healthQuery = null, System.Action? configure = null, string? name = null, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus = default, System.Collections.Generic.IEnumerable? tags = null, System.TimeSpan? timeout = default) { } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddMySql(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string connectionString, string? healthQuery = null, System.Action? configure = null, string? name = null, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus = default, System.Collections.Generic.IEnumerable? tags = null, System.TimeSpan? timeout = default) { } } } \ No newline at end of file