Skip to content

Commit

Permalink
Use MySqlDataSource in AddMySql by default (#2096)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
bgrainger authored Dec 11, 2023
1 parent fb04fe8 commit 5be2f55
Show file tree
Hide file tree
Showing 8 changed files with 241 additions and 39 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,50 @@ namespace Microsoft.Extensions.DependencyInjection;
public static class MySqlHealthCheckBuilderExtensions
{
private const string NAME = "mysql";
internal const string HEALTH_QUERY = "SELECT 1;";

/// <summary>
/// Add a health check for MySQL databases.
/// </summary>
/// <param name="builder">The <see cref="IHealthChecksBuilder"/>.</param>
/// <param name="dataSourceFactory">An optional factory to create the <see cref="MySqlDataSource"/>. By default, one will be retrieved from the service collection.</param>
/// <param name="healthQuery">The optional query to be executed. If this is <c>null</c>, a MySQL "ping" packet will be sent to the server instead of a query.</param>
/// <param name="configure">An optional action to allow additional MySQL specific configuration.</param>
/// <param name="name">The health check name. Optional. If <c>null</c> the type name 'mysql' will be used for the name.</param>
/// <param name="failureStatus">
/// The <see cref="HealthStatus"/> that should be reported when the health check fails. Optional. If <c>null</c> then
/// the default status of <see cref="HealthStatus.Unhealthy"/> will be reported.
/// </param>
/// <param name="tags">A list of tags that can be used to filter sets of health checks. Optional.</param>
/// <param name="timeout">An optional <see cref="TimeSpan"/> representing the timeout of the check.</param>
/// <returns>The specified <paramref name="builder"/>.</returns>
public static IHealthChecksBuilder AddMySql(
this IHealthChecksBuilder builder,
Func<IServiceProvider, MySqlDataSource>? dataSourceFactory = null,
string? healthQuery = null,
Action<MySqlConnection>? configure = null,
string? name = default,
HealthStatus? failureStatus = default,
IEnumerable<string>? tags = default,
TimeSpan? timeout = default)
{
return builder.Add(new HealthCheckRegistration(
name ?? NAME,
sp => new MySqlHealthCheck(new(dataSourceFactory?.Invoke(sp) ?? sp.GetRequiredService<MySqlDataSource>())
{
CommandText = healthQuery,
Configure = configure,
}),
failureStatus,
tags,
timeout));
}

/// <summary>
/// Add a health check for MySQL databases.
/// </summary>
/// <param name="builder">The <see cref="IHealthChecksBuilder"/>.</param>
/// <param name="connectionString">The MySQL connection string to be used.</param>
/// <param name="healthQuery">The query to be executed.</param>
/// <param name="healthQuery">The optional query to be executed. If this is <c>null</c>, a MySQL "ping" packet will be sent to the server instead of a query.</param>
/// <param name="configure">An optional action to allow additional MySQL specific configuration.</param>
/// <param name="name">The health check name. Optional. If <c>null</c> the type name 'mysql' will be used for the name.</param>
/// <param name="failureStatus">
Expand All @@ -30,22 +66,28 @@ public static class MySqlHealthCheckBuilderExtensions
public static IHealthChecksBuilder AddMySql(
this IHealthChecksBuilder builder,
string connectionString,
string healthQuery = HEALTH_QUERY,
string? healthQuery = null,
Action<MySqlConnection>? configure = null,
string? name = default,
HealthStatus? failureStatus = default,
IEnumerable<string>? 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);
}

/// <summary>
/// Add a health check for MySQL databases.
/// </summary>
/// <param name="builder">The <see cref="IHealthChecksBuilder"/>.</param>
/// <param name="connectionStringFactory">A factory to build the MySQL connection string to use.</param>
/// <param name="healthQuery">The query to be executed.</param>
/// <param name="healthQuery">The optional query to be executed. If this is <c>null</c>, a MySQL "ping" packet will be sent to the server instead of a query.</param>
/// <param name="configure">An optional action to allow additional MySQL specific configuration.</param>
/// <param name="name">The health check name. Optional. If <c>null</c> the type name 'mysql' will be used for the name.</param>
/// <param name="failureStatus">
Expand All @@ -58,7 +100,7 @@ public static IHealthChecksBuilder AddMySql(
public static IHealthChecksBuilder AddMySql(
this IHealthChecksBuilder builder,
Func<IServiceProvider, string> connectionStringFactory,
string healthQuery = HEALTH_QUERY,
string? healthQuery = null,
Action<MySqlConnection>? configure = null,
string? name = default,
HealthStatus? failureStatus = default,
Expand All @@ -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));
Expand Down
33 changes: 24 additions & 9 deletions src/HealthChecks.MySql/MySqlHealthCheck.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -22,18 +25,30 @@ public async Task<HealthCheckResult> 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)
{
Expand Down
48 changes: 43 additions & 5 deletions src/HealthChecks.MySql/MySqlHealthCheckOptions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using MySqlConnector;

Expand All @@ -8,16 +7,55 @@ namespace HealthChecks.MySql;
/// Options for <see cref="MySqlHealthCheck"/>.
/// </summary>
public class MySqlHealthCheckOptions
{
{
/// <summary>
/// Creates an instance of <see cref="MySqlHealthCheckOptions"/>.
/// </summary>
/// <param name="dataSource">The <see cref="MySqlDataSource" /> to be used.</param>
/// <remarks>
/// Depending on how the <see cref="MySqlDataSource" /> was configured, the connections it hands out may be pooled.
/// That is why it should be the exact same <see cref="MySqlDataSource" /> that is used by other parts of your app.
/// </remarks>
public MySqlHealthCheckOptions(MySqlDataSource dataSource)
{
DataSource = Guard.ThrowIfNull(dataSource);
}

/// <summary>
/// Creates an instance of <see cref="MySqlHealthCheckOptions"/>.
/// </summary>
/// <param name="connectionString">The MySQL connection string to be used.</param>
/// <remarks>
/// <see cref="MySqlDataSource"/> supports additional configuration beyond the connection string, such as logging and naming pools for diagnostics.
/// To specify a data source, use <see cref=" MySqlDataSourceBuilder"/> and the <see cref="MySqlHealthCheckOptions(MySqlDataSource)"/> constructor.
/// </remarks>
public MySqlHealthCheckOptions(string connectionString)
{
ConnectionString = Guard.ThrowIfNull(connectionString, throwOnEmptyString: true);
}

/// <summary>
/// The MySQL data source to be used.
/// </summary>
/// <remarks>
/// Depending on how the <see cref="MySqlDataSource" /> was configured, the connections it hands out may be pooled.
/// That is why it should be the exact same <see cref="MySqlDataSource" /> that is used by other parts of your app.
/// </remarks>
public MySqlDataSource? DataSource { get; }

/// <summary>
/// The MySQL connection string to be used.
/// The MySQL connection string to be used, if <see cref="DataSource"/> isn't set.
/// </summary>
public string ConnectionString { get; set; } = null!;
/// <remarks>
/// <see cref="MySqlDataSource"/> supports additional configuration beyond the connection string, such as logging and naming pools for diagnostics.
/// To specify a data source, use <see cref=" MySqlDataSourceBuilder"/> and the <see cref="MySqlHealthCheckOptions(MySqlDataSource)"/> constructor.
/// </remarks>
public string? ConnectionString { get; }

/// <summary>
/// The query to be executed.
/// </summary>
public string CommandText { get; set; } = MySqlHealthCheckBuilderExtensions.HEALTH_QUERY;
public string? CommandText { get; set; }

/// <summary>
/// An optional action executed before the connection is opened in the health check.
Expand Down
59 changes: 59 additions & 0 deletions src/HealthChecks.MySql/README.md
Original file line number Diff line number Diff line change
@@ -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.

Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using MySqlConnector;

namespace HealthChecks.MySql.Tests.DependencyInjection;

public class mysql_registration_should
Expand All @@ -18,6 +20,7 @@ public void add_health_check_when_properly_configured()
registration.Name.ShouldBe("mysql");
check.ShouldBeOfType<MySqlHealthCheck>();
}

[Fact]
public void add_named_health_check_when_properly_configured()
{
Expand All @@ -34,4 +37,22 @@ public void add_named_health_check_when_properly_configured()
registration.Name.ShouldBe("my-mysql-group");
check.ShouldBeOfType<MySqlHealthCheck>();
}

[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<IOptions<HealthCheckServiceOptions>>();

var registration = options.Value.Registrations.First();
var check = registration.Factory(serviceProvider);

registration.Name.ShouldBe("mysql");
check.ShouldBeOfType<MySqlHealthCheck>();
}
}
35 changes: 30 additions & 5 deletions test/HealthChecks.MySql.Tests/Functional/MySqlHealthCheckTests.cs
Original file line number Diff line number Diff line change
@@ -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!";

Expand Down Expand Up @@ -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" });
})
Expand Down
4 changes: 4 additions & 0 deletions test/HealthChecks.MySql.Tests/HealthChecks.MySql.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">

<ItemGroup>
<PackageReference Include="MySqlConnector.DependencyInjection" Version="2.3.1" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\HealthChecks.MySql\HealthChecks.MySql.csproj" />
</ItemGroup>
Expand Down
Loading

0 comments on commit 5be2f55

Please sign in to comment.