Skip to content

Commit b1344c6

Browse files
AarononthewebEmir Birgiçbeminee
authored
[WIP] Dependency Injected HealthChecks (#659)
* Add dependency injection support health checks. - Add WithHealthCheck<T>() generic methods for DI-resolved health checks - Support both simple registration and template-based configuration - Implement DiResolvedHealthCheck<T> wrapper for lazy DI resolution * Updated CoreApiSpec.ApproveCore.verified.txt to include new APIs * Reverted global.json * Removed DI registration logic. Now should throw 'InvalidOperationException' if the health check type is not registered * eliminate need for HealthCheck ServiceCollection registrations * API approvals * fixed DI demo --------- Co-authored-by: Emir Birgiç <emir.birgic@theyr.com> Co-authored-by: (ノ°Д°)ノ︵ ┻━┻ <beminee@users.noreply.github.com>
1 parent 6207fa1 commit b1344c6

File tree

7 files changed

+234
-46
lines changed

7 files changed

+234
-46
lines changed

src/Akka.Hosting.API.Tests/verify/CoreApiSpec.ApproveCore.verified.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ namespace Akka.Hosting
4646
where T : Akka.Actor.IExtensionId { }
4747
public Akka.Hosting.AkkaConfigurationBuilder WithExtensions(params System.Type[] extensions) { }
4848
public Akka.Hosting.AkkaConfigurationBuilder WithHealthCheck(Akka.Hosting.AkkaHealthCheckRegistration registration) { }
49+
public Akka.Hosting.AkkaConfigurationBuilder WithHealthCheck<T>(string name, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus = default, System.Collections.Generic.IEnumerable<string>? tags = null, System.TimeSpan? timeout = default)
50+
where T : class, Akka.Hosting.IAkkaHealthCheck { }
4951
}
5052
public sealed class AkkaHealthCheckContext
5153
{
@@ -58,8 +60,8 @@ namespace Akka.Hosting
5860
{
5961
public AkkaHealthCheckRegistration(string name, Akka.Hosting.IAkkaHealthCheck instance, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus, System.Collections.Generic.IEnumerable<string>? tags) { }
6062
public AkkaHealthCheckRegistration(string name, Akka.Hosting.IAkkaHealthCheck instance, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus, System.Collections.Generic.IEnumerable<string>? tags, System.TimeSpan? timeout) { }
63+
public System.Func<System.IServiceProvider, Akka.Hosting.IAkkaHealthCheck> Factory { get; set; }
6164
public Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus FailureStatus { get; set; }
62-
public Akka.Hosting.IAkkaHealthCheck HealthCheck { get; set; }
6365
public string Name { get; set; }
6466
public System.Collections.Generic.ISet<string> Tags { get; }
6567
public System.TimeSpan Timeout { get; set; }

src/Akka.Hosting.Tests/HealthChecks/HealthChecksSpec.cs

Lines changed: 135 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,19 @@
66
using Akka.Hosting.HealthChecks;
77
using Microsoft.Extensions.DependencyInjection;
88
using Microsoft.Extensions.Diagnostics.HealthChecks;
9+
using Microsoft.Extensions.Hosting;
910
using Xunit;
1011
using Xunit.Abstractions;
1112

1213
namespace Akka.Hosting.Tests.HealthChecks;
1314

1415
public class HealthChecksSpec : TestKit.TestKit
1516
{
16-
public HealthChecksSpec(ITestOutputHelper output)
17-
: base(output: output){ }
18-
17+
public HealthChecksSpec(ITestOutputHelper output)
18+
: base(output: output)
19+
{
20+
}
21+
1922
private class FooActor : UntypedActor
2023
{
2124
protected override void OnReceive(object message)
@@ -28,61 +31,64 @@ protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IService
2831
builder
2932
.WithActorSystemLivenessCheck() // have to opt-in to the built-in health check
3033
.WithHealthCheck("FooActor alive", async (system, registry, cancellationToken) =>
31-
{
32-
/*
33-
* N.B. CancellationToken is set by the call to MSFT.EXT.DIAGNOSTICS.HEALTHCHECK,
34-
* so that value could be "infinite" by default.
35-
*
36-
* Therefore, it might be a really, really good idea to guard this with a non-infinite
37-
* timeout via a LinkedCancellationToken here.
38-
*/
39-
try
4034
{
41-
var fooActor = await registry.GetAsync<FooActor>(cancellationToken);
42-
35+
/*
36+
* N.B. CancellationToken is set by the call to MSFT.EXT.DIAGNOSTICS.HEALTHCHECK,
37+
* so that value could be "infinite" by default.
38+
*
39+
* Therefore, it might be a really, really good idea to guard this with a non-infinite
40+
* timeout via a LinkedCancellationToken here.
41+
*/
4342
try
4443
{
45-
var r = await fooActor.Ask<ActorIdentity>(new Identify("foo"), cancellationToken: cancellationToken);
46-
if (r.Subject.IsNobody())
47-
return HealthCheckResult.Unhealthy("FooActor was alive but is now dead");
44+
var fooActor = await registry.GetAsync<FooActor>(cancellationToken);
45+
46+
try
47+
{
48+
var r = await fooActor.Ask<ActorIdentity>(new Identify("foo"),
49+
cancellationToken: cancellationToken);
50+
if (r.Subject.IsNobody())
51+
return HealthCheckResult.Unhealthy("FooActor was alive but is now dead");
52+
}
53+
catch (Exception e)
54+
{
55+
return HealthCheckResult.Degraded("FooActor found but non-responsive", e);
56+
}
4857
}
49-
catch (Exception e)
58+
catch (Exception e2)
5059
{
51-
return HealthCheckResult.Degraded("FooActor found but non-responsive", e);
60+
return HealthCheckResult.Unhealthy("FooActor not found in registry", e2);
5261
}
53-
}
54-
catch (Exception e2)
55-
{
56-
return HealthCheckResult.Unhealthy("FooActor not found in registry", e2);
57-
}
5862

59-
return HealthCheckResult.Healthy("fooActor found and responsive");
60-
});
63+
return HealthCheckResult.Healthy("fooActor found and responsive");
64+
});
6165
}
6266

6367
[Fact]
6468
public async Task ShouldHaveDefaultHealthCheckRegistration()
6569
{
6670
// arrange
6771
var configurationBuilder = Host.Services.GetRequiredService<AkkaConfigurationBuilder>();
68-
72+
6973
// act
70-
74+
7175
// assert
7276
Assert.Equal(2, configurationBuilder.HealthChecks.Count); // 1 built-in, 1 custom
73-
77+
7478
// find the built-in implementation
75-
var actorSystemHealthCheckRegistration = configurationBuilder.HealthChecks.Values.Single(c => c.HealthCheck is ActorSystemLivenessCheck);
79+
var actorSystemHealthCheckRegistration =
80+
configurationBuilder.HealthChecks.Values.Single(c => c.Factory(Host.Services) is ActorSystemLivenessCheck);
7681
var akkaHealthCheckContext = new AkkaHealthCheckContext(Sys)
7782
{ Registration = actorSystemHealthCheckRegistration.ToHealthCheckRegistration() };
78-
83+
7984
// invoke the actorSystem liveness check
80-
var healthCheckResult = await actorSystemHealthCheckRegistration.HealthCheck.CheckHealthAsync(akkaHealthCheckContext, CancellationToken.None);
81-
85+
var healthCheck = actorSystemHealthCheckRegistration.Factory(Host.Services);
86+
var healthCheckResult = await healthCheck.CheckHealthAsync(akkaHealthCheckContext, CancellationToken.None);
87+
8288
// assert - system is alive, health check should be healthy
8389
Assert.Equal(HealthStatus.Healthy, healthCheckResult.Status);
8490
}
85-
91+
8692
[Fact]
8793
public async Task ShouldReturnAppropriateResults()
8894
{
@@ -91,11 +97,11 @@ public async Task ShouldReturnAppropriateResults()
9197

9298
// act
9399
var customActorHealthCheck =
94-
configurationBuilder.HealthChecks.Values.Single(c => c.HealthCheck is DelegateHealthCheck);
95-
100+
configurationBuilder.HealthChecks.Values.Single(c => c.Factory(Host.Services) is DelegateHealthCheck);
101+
96102
var akkaHealthCheckContext = new AkkaHealthCheckContext(Sys)
97103
{ Registration = customActorHealthCheck.ToHealthCheckRegistration() };
98-
104+
99105
// should fail - target actor is not alive
100106
await InvokeHealthCheck(HealthStatus.Unhealthy);
101107

@@ -105,7 +111,7 @@ public async Task ShouldReturnAppropriateResults()
105111

106112
// should succeed - target actor is around
107113
await InvokeHealthCheck(HealthStatus.Healthy, 3000);
108-
114+
109115
// kill the target actor
110116
await WatchAsync(fooActor);
111117
fooActor.Tell(PoisonPill.Instance);
@@ -118,10 +124,99 @@ public async Task ShouldReturnAppropriateResults()
118124
async Task InvokeHealthCheck(HealthStatus expectedStatus, int waitMilliseconds = 1)
119125
{
120126
using var fastCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(waitMilliseconds));
121-
var healthCheckResult = await customActorHealthCheck.HealthCheck.CheckHealthAsync(akkaHealthCheckContext, fastCts.Token);
122-
if(healthCheckResult.Description != null)
127+
var healthCheck = customActorHealthCheck.Factory(Host.Services);
128+
var healthCheckResult = await healthCheck.CheckHealthAsync(akkaHealthCheckContext, fastCts.Token);
129+
if (healthCheckResult.Description != null)
123130
Output?.WriteLine(healthCheckResult.Description);
124131
Assert.Equal(expectedStatus, healthCheckResult.Status);
125132
}
126133
}
134+
135+
[Fact]
136+
public async Task ShouldResolveDiHealthCheckFromContainer()
137+
{
138+
// arrange
139+
var configurationBuilder = Host.Services.GetRequiredService<AkkaConfigurationBuilder>();
140+
141+
// act - add a DI-resolved health check to the existing configuration
142+
configurationBuilder.WithHealthCheck<TestDiHealthCheck>("test-di-health");
143+
144+
// assert
145+
Assert.Equal(3, configurationBuilder.HealthChecks.Count); // 2 from base configuration + 1 DI-resolved
146+
147+
// find the DI-resolved health check
148+
var diHealthCheckRegistration =
149+
configurationBuilder.HealthChecks.Values.Single(c => c.Name == "test-di-health");
150+
var akkaHealthCheckContext = new AkkaHealthCheckContext(Sys)
151+
{
152+
Registration = diHealthCheckRegistration.ToHealthCheckRegistration()
153+
};
154+
155+
// invoke the DI-resolved health check - TestDiHealthCheck is registered in ConfigureServices
156+
var healthCheck = diHealthCheckRegistration.Factory(Host.Services);
157+
var healthCheckResult = await healthCheck.CheckHealthAsync(akkaHealthCheckContext, CancellationToken.None);
158+
159+
// assert - health check should be healthy
160+
Assert.Equal(HealthStatus.Healthy, healthCheckResult.Status);
161+
Assert.Equal("Test DI health check is working with DI", healthCheckResult.Description);
162+
}
163+
164+
[Fact]
165+
public async Task ShouldResolveDiHealthCheckWithRegistrationTemplate()
166+
{
167+
// arrange
168+
var configurationBuilder = Host.Services.GetRequiredService<AkkaConfigurationBuilder>();
169+
170+
// act - add a DI-resolved health check using the registration template pattern
171+
configurationBuilder.WithHealthCheck<TestDiHealthCheck>(
172+
"template-test",
173+
HealthStatus.Degraded,
174+
[
175+
"custom", "test"
176+
]);
177+
178+
// assert
179+
Assert.Equal(3, configurationBuilder.HealthChecks.Count); // 2 from base configuration + 1 DI-resolved
180+
181+
// find the DI-resolved health check
182+
var diHealthCheckRegistration = configurationBuilder.HealthChecks.Values.Single(c => c.Name == "template-test");
183+
Assert.Equal(HealthStatus.Degraded, diHealthCheckRegistration.FailureStatus);
184+
Assert.Contains("custom", diHealthCheckRegistration.Tags);
185+
Assert.Contains("test", diHealthCheckRegistration.Tags);
186+
187+
var akkaHealthCheckContext = new AkkaHealthCheckContext(Sys)
188+
{ Registration = diHealthCheckRegistration.ToHealthCheckRegistration() };
189+
190+
// invoke the health check
191+
var healthCheck = diHealthCheckRegistration.Factory(Host.Services);
192+
var healthCheckResult = await healthCheck.CheckHealthAsync(akkaHealthCheckContext, CancellationToken.None);
193+
194+
// assert
195+
Assert.Equal(HealthStatus.Healthy, healthCheckResult.Status);
196+
Assert.Equal("Test DI health check is working with DI", healthCheckResult.Description);
197+
}
198+
199+
/// <summary>
200+
/// Test health check class that requires DI (simulates ILogger dependency)
201+
/// </summary>
202+
private class TestDiHealthCheck : IAkkaHealthCheck
203+
{
204+
private readonly IServiceProvider? _serviceProvider;
205+
206+
public TestDiHealthCheck(IServiceProvider serviceProvider)
207+
{
208+
// Constructor with DI dependency
209+
_serviceProvider = serviceProvider;
210+
}
211+
212+
public Task<HealthCheckResult> CheckHealthAsync(AkkaHealthCheckContext context,
213+
CancellationToken cancellationToken = default)
214+
{
215+
// Verify that dependencies can be resolved (simulates ILogger usage)
216+
var hasServiceProvider = _serviceProvider != null;
217+
218+
return Task.FromResult(HealthCheckResult.Healthy("Test DI health check is working" +
219+
(hasServiceProvider ? " with DI" : "")));
220+
}
221+
}
127222
}

src/Akka.Hosting/AkkaConfigurationBuilder.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.Collections.Immutable;
44
using System.Linq;
55
using System.Reflection;
6+
using System.Threading;
67
using System.Threading.Tasks;
78
using Akka.Actor;
89
using Akka.Actor.Setup;
@@ -348,6 +349,31 @@ public AkkaConfigurationBuilder WithHealthCheck(AkkaHealthCheckRegistration regi
348349
return this;
349350
}
350351

352+
/// <summary>
353+
/// Registers a DI-resolved health check with the <see cref="AkkaConfigurationBuilder"/>.
354+
/// The health check type must be registered in the DI container.
355+
/// </summary>
356+
/// <param name="name">The healthcheck name.</param>
357+
/// <param name="failureStatus">
358+
/// The <see cref="HealthStatus"/> that should be reported upon failure of the health check. If the provided value
359+
/// is <c>null</c>, then <see cref="HealthStatus.Unhealthy"/> will be reported.
360+
/// </param>
361+
/// <param name="tags">A list of tags that can be used for filtering health checks.</param>
362+
/// <param name="timeout">An optional <see cref="TimeSpan"/> representing the timeout of the check.</param>
363+
/// <typeparam name="T">The type of the health check that implements <see cref="IAkkaHealthCheck"/>.</typeparam>
364+
/// <returns>The same <see cref="AkkaConfigurationBuilder"/> instance originally passed in.</returns>
365+
public AkkaConfigurationBuilder WithHealthCheck<T>(string name, HealthStatus? failureStatus = null,
366+
IEnumerable<string>? tags = null, TimeSpan? timeout = null) where T : class, IAkkaHealthCheck
367+
{
368+
// Create a health check instance that will be resolved from DI when needed
369+
var registration = new AkkaHealthCheckRegistration(name, GetServiceOrCreateInstance, failureStatus, tags, timeout);
370+
371+
HealthChecks[registration.Name] = registration;
372+
return this;
373+
374+
static T GetServiceOrCreateInstance(IServiceProvider sp) => ActivatorUtilities.GetServiceOrCreateInstance<T>(sp);
375+
}
376+
351377
internal void Bind()
352378
{
353379
// register as singleton - not interested in supporting multi-Sys use cases

src/Akka.Hosting/HealthChecks/AkkaHealthCheckExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ public static HealthCheckRegistration ToHealthCheckRegistration(this AkkaHealthC
1717
{
1818
// func for lazily instantiating the health check registration
1919
Func<IServiceProvider, IHealthCheck> adapter = provider =>
20-
new HealthCheckAdapter(registration.HealthCheck, provider.GetRequiredService<ActorSystem>());
20+
new HealthCheckAdapter(registration.Factory(provider), provider.GetRequiredService<ActorSystem>());
2121

2222
var tags = registration.Tags;
2323
tags.Add(AkkaTag);

src/Akka.Hosting/IAkkaHealthCheck.cs

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ namespace Akka.Hosting;
1616
/// </remarks>
1717
public sealed class AkkaHealthCheckRegistration
1818
{
19-
private IAkkaHealthCheck _healthCheck;
19+
private Func<IServiceProvider, IAkkaHealthCheck> _healthCheck;
2020
private string _name;
2121
private TimeSpan _timeout;
2222

@@ -65,7 +65,42 @@ public AkkaHealthCheckRegistration(string name, IAkkaHealthCheck instance, Healt
6565
_name = name;
6666
FailureStatus = failureStatus ?? HealthStatus.Unhealthy;
6767
Tags = new HashSet<string>(tags ?? [], StringComparer.OrdinalIgnoreCase);
68-
_healthCheck = instance;
68+
_healthCheck = _ => instance;
69+
Timeout = timeout ?? System.Threading.Timeout.InfiniteTimeSpan;
70+
}
71+
72+
/// <summary>
73+
/// Creates a new <see cref="AkkaHealthCheckRegistration"/> template for use with DI-resolved health checks.
74+
/// This constructor is intended for use with generic health check registration methods.
75+
/// </summary>
76+
/// <param name="name">The healthcheck name.</param>
77+
/// <param name="factory">The DI-enabled factory.</param>
78+
/// <param name="failureStatus">
79+
/// The <see cref="HealthStatus"/> that should be reported upon failure of the health check. If the provided value
80+
/// is <c>null</c>, then <see cref="HealthStatus.Unhealthy"/> will be reported.
81+
/// </param>
82+
/// <param name="tags">A list of tags that can be used for filtering health checks.</param>
83+
/// <param name="timeout">An optional <see cref="TimeSpan"/> representing the timeout of the check.</param>
84+
/// <exception cref="ArgumentNullException">Thrown if <see cref="name"/> is null.</exception>
85+
/// <exception cref="ArgumentOutOfRangeException">Thrown if a negative timeout other than <see cref="System.Threading.Timeout.InfiniteTimeSpan"/> is used.</exception>
86+
internal AkkaHealthCheckRegistration(string name, Func<IServiceProvider, IAkkaHealthCheck> factory, HealthStatus? failureStatus,
87+
IEnumerable<string>? tags, TimeSpan? timeout)
88+
{
89+
if (name == null)
90+
{
91+
throw new ArgumentNullException(nameof(name));
92+
}
93+
if(factory == null) throw new ArgumentNullException(nameof(factory));
94+
95+
if (timeout <= TimeSpan.Zero && timeout != System.Threading.Timeout.InfiniteTimeSpan)
96+
{
97+
throw new ArgumentOutOfRangeException(nameof(timeout));
98+
}
99+
100+
_name = name;
101+
FailureStatus = failureStatus ?? HealthStatus.Unhealthy;
102+
Tags = new HashSet<string>(tags ?? [], StringComparer.OrdinalIgnoreCase);
103+
_healthCheck = factory;
69104
Timeout = timeout ?? System.Threading.Timeout.InfiniteTimeSpan;
70105
}
71106

@@ -82,7 +117,7 @@ public string Name
82117
/// Gets or sets the <see cref="IAkkaHealthCheck"/>
83118
/// </summary>
84119
/// <exception cref="ArgumentNullException"></exception>
85-
public IAkkaHealthCheck HealthCheck
120+
public Func<IServiceProvider,IAkkaHealthCheck> Factory
86121
{
87122
get => _healthCheck;
88123
set => _healthCheck = value ?? throw new ArgumentNullException(nameof(value));

src/Examples/Akka.Hosting.Asp.LoggingDemo/Program.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using Akka.Hosting.Asp.LoggingDemo;
88
using Akka.Remote.Hosting;
99
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
10+
using Microsoft.Extensions.Diagnostics.HealthChecks;
1011
using LogLevel = Akka.Event.LogLevel;
1112

1213
var builder = WebApplication.CreateBuilder(args);
@@ -34,8 +35,9 @@
3435
Roles = ["myRole"],
3536
SeedNodes = ["akka.tcp://MyActorSystem@localhost:8110"]
3637
})
37-
.WithActorSystemLivenessCheck()
3838
.WithAkkaClusterReadinessCheck()
39+
.WithActorSystemLivenessCheck()
40+
.WithHealthCheck<TestHealth>("di-test", HealthStatus.Unhealthy, new[] { "test", "custom" })
3941
.WithActors((system, registry) =>
4042
{
4143
var echo = system.ActorOf(act =>

0 commit comments

Comments
 (0)