From f5fa818aea5916d160673396d09789e55713016d Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 14 Oct 2024 15:07:31 +1100 Subject: [PATCH 1/3] Introduce async factories to Redis health check. --- Directory.Build.props | 2 +- .../RedisHealthCheckBuilderExtensions.cs | 62 +++++++++++++++++++ src/HealthChecks.Redis/RedisHealthCheck.cs | 39 ++++++++---- .../DependencyInjection/RegistrationTests.cs | 52 ++++++++++++++++ .../HealthChecks.Redis.approved.txt | 3 + 5 files changed, 146 insertions(+), 12 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index a293c172de..7939e24bd0 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -8,7 +8,7 @@ true embedded true - true + false enable $(NoWarn);1591;IDISP013;AD0001; true diff --git a/src/HealthChecks.Redis/DependencyInjection/RedisHealthCheckBuilderExtensions.cs b/src/HealthChecks.Redis/DependencyInjection/RedisHealthCheckBuilderExtensions.cs index b1d2d638ab..a50d2323d1 100644 --- a/src/HealthChecks.Redis/DependencyInjection/RedisHealthCheckBuilderExtensions.cs +++ b/src/HealthChecks.Redis/DependencyInjection/RedisHealthCheckBuilderExtensions.cs @@ -66,6 +66,37 @@ public static IHealthChecksBuilder AddRedis( timeout)); } + /// + /// Add a health check for Redis services. + /// + /// The . + /// A factory to build the Redis connection string to use. + /// The health check name. Optional. If null the type name 'redis' 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 AddRedis( + this IHealthChecksBuilder builder, + Func> connectionStringFactory, + string? name = default, + HealthStatus? failureStatus = default, + IEnumerable? tags = default, + TimeSpan? timeout = default) + { + Guard.ThrowIfNull(connectionStringFactory); + + return builder.Add(new HealthCheckRegistration( + name ?? NAME, + sp => new RedisHealthCheck((ct) => connectionStringFactory(sp, ct)), + failureStatus, + tags, + timeout)); + } + /// /// Add a health check for Redis services. /// @@ -120,4 +151,35 @@ public static IHealthChecksBuilder AddRedis( tags, timeout)); } + + /// + /// Add a health check for Redis services. + /// + /// The . + /// A factory to build the Redis connection to use. + /// The health check name. Optional. If null the type name 'redis' 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 AddRedis( + this IHealthChecksBuilder builder, + Func> connectionMultiplexerFactory, + string? name = default, + HealthStatus? failureStatus = default, + IEnumerable? tags = default, + TimeSpan? timeout = default) + { + Guard.ThrowIfNull(connectionMultiplexerFactory); + + return builder.Add(new HealthCheckRegistration( + name ?? NAME, + sp => new RedisHealthCheck((ct) => connectionMultiplexerFactory(sp, ct)), + failureStatus, + tags, + timeout)); + } } diff --git a/src/HealthChecks.Redis/RedisHealthCheck.cs b/src/HealthChecks.Redis/RedisHealthCheck.cs index 48a163674d..5b97c39189 100644 --- a/src/HealthChecks.Redis/RedisHealthCheck.cs +++ b/src/HealthChecks.Redis/RedisHealthCheck.cs @@ -9,14 +9,19 @@ namespace HealthChecks.Redis; /// public class RedisHealthCheck : IHealthCheck { - private static readonly ConcurrentDictionary _connections = new(); - private readonly string? _redisConnectionString; + private static readonly ConcurrentDictionary>, IConnectionMultiplexer> _connections = new(); + private readonly Func>? _redisConnectionStringFactory; private readonly IConnectionMultiplexer? _connectionMultiplexer; - private readonly Func? _connectionMultiplexerFactory; + private readonly Func>? _connectionMultiplexerFactory; public RedisHealthCheck(string redisConnectionString) { - _redisConnectionString = Guard.ThrowIfNull(redisConnectionString); + _redisConnectionStringFactory = (_) => Task.FromResult(Guard.ThrowIfNull(redisConnectionString)); + } + + public RedisHealthCheck(Func> redisConnectionStringFactory) + { + _redisConnectionStringFactory = Guard.ThrowIfNull(redisConnectionStringFactory); } public RedisHealthCheck(IConnectionMultiplexer connectionMultiplexer) @@ -35,22 +40,34 @@ public RedisHealthCheck(IConnectionMultiplexer connectionMultiplexer) /// for the first time, so exceptions can be handled gracefully. /// internal RedisHealthCheck(Func connectionMultiplexerFactory) + { + _connectionMultiplexerFactory = (ct) => Task.FromResult(connectionMultiplexerFactory()); + } + + internal RedisHealthCheck(Func> connectionMultiplexerFactory) { _connectionMultiplexerFactory = connectionMultiplexerFactory; } + /// public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) { try { - IConnectionMultiplexer? connection = _connectionMultiplexer ?? _connectionMultiplexerFactory?.Invoke(); + var connection = (_connectionMultiplexer, _connectionMultiplexerFactory) switch + { + (not null, _) => _connectionMultiplexer, + (null, { } factory) => await factory(cancellationToken).ConfigureAwait(false), + _ => null + }; - if (_redisConnectionString is not null && !_connections.TryGetValue(_redisConnectionString, out connection)) + if (_redisConnectionStringFactory is not null && !_connections.TryGetValue(_redisConnectionStringFactory, out connection)) { try { - var connectionMultiplexerTask = ConnectionMultiplexer.ConnectAsync(_redisConnectionString!); + var redisConnectionString = await _redisConnectionStringFactory(cancellationToken).ConfigureAwait(false); + var connectionMultiplexerTask = ConnectionMultiplexer.ConnectAsync(redisConnectionString!); connection = await TimeoutAsync(connectionMultiplexerTask, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) @@ -58,11 +75,11 @@ public async Task CheckHealthAsync(HealthCheckContext context return new HealthCheckResult(context.Registration.FailureStatus, description: "Healthcheck timed out"); } - if (!_connections.TryAdd(_redisConnectionString, connection)) + if (!_connections.TryAdd(_redisConnectionStringFactory, connection)) { // Dispose new connection which we just created, because we don't need it. connection.Dispose(); - connection = _connections[_redisConnectionString]; + connection = _connections[_redisConnectionStringFactory]; } } @@ -99,9 +116,9 @@ public async Task CheckHealthAsync(HealthCheckContext context } catch (Exception ex) { - if (_redisConnectionString is not null) + if (_redisConnectionStringFactory is not null) { - _connections.TryRemove(_redisConnectionString, out var connection); + _connections.TryRemove(_redisConnectionStringFactory, out var connection); #pragma warning disable IDISP007 // Don't dispose injected [false positive here] connection?.Dispose(); #pragma warning restore IDISP007 // Don't dispose injected diff --git a/test/HealthChecks.Redis.Tests/DependencyInjection/RegistrationTests.cs b/test/HealthChecks.Redis.Tests/DependencyInjection/RegistrationTests.cs index 7513076441..327bf7fe83 100644 --- a/test/HealthChecks.Redis.Tests/DependencyInjection/RegistrationTests.cs +++ b/test/HealthChecks.Redis.Tests/DependencyInjection/RegistrationTests.cs @@ -62,6 +62,29 @@ public void add_health_check_with_connection_string_factory_when_properly_config factoryCalled.ShouldBeTrue(); } + [Fact] + public void add_health_check_with_async_connection_string_factory_when_properly_configured() + { + var services = new ServiceCollection(); + var factoryCalled = false; + services.AddHealthChecks() + .AddRedis((sp, ct) => + { + factoryCalled = true; + return Task.FromResult("connectionstring"); + }); + + using var serviceProvider = services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>(); + + var registration = options.Value.Registrations.First(); + var check = registration.Factory(serviceProvider); + + registration.Name.ShouldBe("redis"); + check.ShouldBeOfType(); + factoryCalled.ShouldBeFalse(); + } + [Fact] public void add_named_health_check_with_connection_multiplexer_when_properly_configured() { @@ -109,4 +132,33 @@ public void add_health_check_with_connection_multiplexer_when_properly_configure // the factory is called when it's used for the first time, as it can throw factoryCalled.ShouldBeFalse(); } + + [Fact] + public void add_health_check_with_async_connection_multiplexer_when_properly_configured() + { + var connectionMultiplexer = Substitute.For(); + var services = new ServiceCollection(); + + services.AddSingleton(connectionMultiplexer); + var factoryCalled = false; + + services.AddHealthChecks() + .AddRedis((sp, _) => + { + factoryCalled = true; + var multiplexer = sp.GetRequiredService(); + return Task.FromResult(multiplexer); + }); + + using var serviceProvider = services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>(); + + var registration = options.Value.Registrations.First(); + var check = registration.Factory(serviceProvider); + + registration.Name.ShouldBe("redis"); + check.ShouldBeOfType(); + // the factory is called when it's used for the first time, as it can throw + factoryCalled.ShouldBeFalse(); + } } diff --git a/test/HealthChecks.Redis.Tests/HealthChecks.Redis.approved.txt b/test/HealthChecks.Redis.Tests/HealthChecks.Redis.approved.txt index 176cfd64fc..e3a0f715ab 100644 --- a/test/HealthChecks.Redis.Tests/HealthChecks.Redis.approved.txt +++ b/test/HealthChecks.Redis.Tests/HealthChecks.Redis.approved.txt @@ -3,6 +3,7 @@ namespace HealthChecks.Redis public class RedisHealthCheck : Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheck { public RedisHealthCheck(StackExchange.Redis.IConnectionMultiplexer connectionMultiplexer) { } + public RedisHealthCheck(System.Func> redisConnectionStringFactory) { } public RedisHealthCheck(string redisConnectionString) { } public System.Threading.Tasks.Task CheckHealthAsync(Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckContext context, System.Threading.CancellationToken cancellationToken = default) { } } @@ -14,6 +15,8 @@ namespace Microsoft.Extensions.DependencyInjection public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddRedis(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, StackExchange.Redis.IConnectionMultiplexer connectionMultiplexer, 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 AddRedis(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, System.Func connectionMultiplexerFactory, 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 AddRedis(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, System.Func connectionStringFactory, 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 AddRedis(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, System.Func> connectionMultiplexerFactory, 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 AddRedis(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, System.Func> connectionStringFactory, 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 AddRedis(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string redisConnectionString, 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 From ee976e08213fb03641bf9eed50aa9202133cb2c4 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 25 Oct 2024 17:12:45 +1100 Subject: [PATCH 2/3] Update Directory.Build.props Co-authored-by: Eric Erhardt --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 7939e24bd0..a293c172de 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -8,7 +8,7 @@ true embedded true - false + true enable $(NoWarn);1591;IDISP013;AD0001; true From 53c312ff31e464ea549d29014e3ffcd8f5d5097e Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 25 Oct 2024 17:12:51 +1100 Subject: [PATCH 3/3] Update src/HealthChecks.Redis/RedisHealthCheck.cs Co-authored-by: Eric Erhardt --- src/HealthChecks.Redis/RedisHealthCheck.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/HealthChecks.Redis/RedisHealthCheck.cs b/src/HealthChecks.Redis/RedisHealthCheck.cs index 5b97c39189..14f54a8647 100644 --- a/src/HealthChecks.Redis/RedisHealthCheck.cs +++ b/src/HealthChecks.Redis/RedisHealthCheck.cs @@ -49,7 +49,6 @@ internal RedisHealthCheck(Func> _connectionMultiplexerFactory = connectionMultiplexerFactory; } - /// public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) {