From 25dd328280c7190472691aef856d74eb500c287d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 06:47:29 -0400 Subject: [PATCH] feat(health.akka): cluster health check with configurable status policy --- .../AkkaClusterHealthCheck.cs | 51 +++++++ .../AkkaClusterStatusPolicy.cs | 56 ++++++++ .../AkkaClusterStatusPolicyTests.cs | 75 ++++++++++ .../DatabaseHealthCheckTests.cs | 133 ++++++++++++++++++ 4 files changed, 315 insertions(+) create mode 100644 ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.Akka/AkkaClusterHealthCheck.cs create mode 100644 ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.Akka/AkkaClusterStatusPolicy.cs create mode 100644 ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Akka.Tests/AkkaClusterStatusPolicyTests.cs create mode 100644 ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.EntityFrameworkCore.Tests/DatabaseHealthCheckTests.cs diff --git a/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.Akka/AkkaClusterHealthCheck.cs b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.Akka/AkkaClusterHealthCheck.cs new file mode 100644 index 0000000..3deab3c --- /dev/null +++ b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.Akka/AkkaClusterHealthCheck.cs @@ -0,0 +1,51 @@ +using Akka.Actor; +using Akka.Cluster; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace ZB.MOM.WW.Health.Akka; + +/// +/// Health check that maps the local node's Akka cluster membership status to a +/// through a configurable . +/// Register to the tag (recommended [ready, active]). +/// +/// +/// The is resolved lazily from the service provider. If it is not yet +/// available — e.g. during startup before Akka is initialised — the check returns +/// rather than throwing, so it is safe to register before Akka +/// is fully up. +/// +public sealed class AkkaClusterHealthCheck : IHealthCheck +{ + private readonly IServiceProvider _serviceProvider; + private readonly AkkaClusterStatusPolicy _policy; + + /// Initializes a new . + /// + /// The application service provider. The is resolved lazily so the + /// check is startup-safe: if no is registered yet the result is Degraded. + /// + /// The status-to-health mapping policy to apply. + public AkkaClusterHealthCheck(IServiceProvider serviceProvider, AkkaClusterStatusPolicy policy) + { + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _policy = policy ?? throw new ArgumentNullException(nameof(policy)); + } + + /// + public Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + var system = _serviceProvider.GetService(); + if (system is null) + return Task.FromResult(HealthCheckResult.Degraded("ActorSystem not yet available.")); + + var status = Cluster.Get(system).SelfMember.Status; + var health = _policy.Evaluate(status); + var description = $"Akka cluster member status: {status}"; + + return Task.FromResult(new HealthCheckResult(health, description)); + } +} diff --git a/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.Akka/AkkaClusterStatusPolicy.cs b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.Akka/AkkaClusterStatusPolicy.cs new file mode 100644 index 0000000..fd01a9b --- /dev/null +++ b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.Akka/AkkaClusterStatusPolicy.cs @@ -0,0 +1,56 @@ +using Akka.Cluster; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace ZB.MOM.WW.Health.Akka; + +/// +/// Pure mapping from an Akka to a . +/// +/// +/// +/// Wraps a so the decision logic is a deterministic, +/// table-testable function — only supplies the live cluster +/// status. Two named presets reconcile the divergence between the existing ScadaBridge and OtOpcUa +/// implementations; construct a custom instance for project-specific overrides. +/// +/// +public sealed class AkkaClusterStatusPolicy +{ + private readonly Func _evaluate; + + /// Initializes a new . + /// The pure status-to-health mapping function. + public AkkaClusterStatusPolicy(Func evaluate) + { + _evaluate = evaluate ?? throw new ArgumentNullException(nameof(evaluate)); + } + + /// Applies the policy to the given member status. + /// The local node's Akka cluster member status. + /// The mapped . + public HealthStatus Evaluate(MemberStatus status) => _evaluate(status); + + /// + /// ScadaBridge origin: Up/Joining → Healthy, Leaving/Exiting → + /// Degraded, everything else → Unhealthy. The convergence target for all projects. + /// + public static AkkaClusterStatusPolicy Default { get; } = new(static status => status switch + { + MemberStatus.Up or MemberStatus.Joining => HealthStatus.Healthy, + MemberStatus.Leaving or MemberStatus.Exiting => HealthStatus.Degraded, + _ => HealthStatus.Unhealthy, + }); + + /// + /// OtOpcUa origin: self-Up-among-reachable-members → Healthy, any non-Up state + /// (including Leaving/Exiting/Down) → Degraded. Provided for backward + /// compatibility during OtOpcUa's migration. + /// + /// + /// The original OtOpcUa check scanned the reachable member set for self with + /// Status == Up; any other state caused the scan to miss self and collapse to Degraded. + /// This preset reproduces that behavior: only is Healthy. + /// + public static AkkaClusterStatusPolicy OtOpcUaCompat { get; } = new(static status => + status == MemberStatus.Up ? HealthStatus.Healthy : HealthStatus.Degraded); +} diff --git a/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Akka.Tests/AkkaClusterStatusPolicyTests.cs b/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Akka.Tests/AkkaClusterStatusPolicyTests.cs new file mode 100644 index 0000000..3e2d347 --- /dev/null +++ b/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Akka.Tests/AkkaClusterStatusPolicyTests.cs @@ -0,0 +1,75 @@ +using Akka.Cluster; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using ZB.MOM.WW.Health.Akka; + +namespace ZB.MOM.WW.Health.Akka.Tests; + +/// +/// Table-driven tests for the pure status-mapping function inside . +/// The two presets ( and +/// ) are the convergence targets for ScadaBridge +/// and OtOpcUa respectively; every is exercised so a drift in either +/// preset fails loudly. Also covers the startup-safety null-guard on . +/// +public sealed class AkkaClusterStatusPolicyTests +{ + public static IEnumerable DefaultCases() => new[] + { + new object[] { MemberStatus.Up, HealthStatus.Healthy }, + new object[] { MemberStatus.Joining, HealthStatus.Healthy }, + new object[] { MemberStatus.Leaving, HealthStatus.Degraded }, + new object[] { MemberStatus.Exiting, HealthStatus.Degraded }, + new object[] { MemberStatus.WeaklyUp, HealthStatus.Unhealthy }, + new object[] { MemberStatus.Down, HealthStatus.Unhealthy }, + new object[] { MemberStatus.Removed, HealthStatus.Unhealthy }, + }; + + [Theory] + [MemberData(nameof(DefaultCases))] + public void Default_MapsEveryStatus(MemberStatus status, HealthStatus expected) + { + Assert.Equal(expected, AkkaClusterStatusPolicy.Default.Evaluate(status)); + } + + public static IEnumerable OtOpcUaCompatCases() => new[] + { + new object[] { MemberStatus.Up, HealthStatus.Healthy }, + new object[] { MemberStatus.Joining, HealthStatus.Degraded }, + new object[] { MemberStatus.Leaving, HealthStatus.Degraded }, + new object[] { MemberStatus.Exiting, HealthStatus.Degraded }, + new object[] { MemberStatus.WeaklyUp, HealthStatus.Degraded }, + new object[] { MemberStatus.Down, HealthStatus.Degraded }, + new object[] { MemberStatus.Removed, HealthStatus.Degraded }, + }; + + [Theory] + [MemberData(nameof(OtOpcUaCompatCases))] + public void OtOpcUaCompat_OnlyUpIsHealthy(MemberStatus status, HealthStatus expected) + { + Assert.Equal(expected, AkkaClusterStatusPolicy.OtOpcUaCompat.Evaluate(status)); + } + + [Fact] + public void CustomPolicy_UsesSuppliedFunc() + { + var policy = new AkkaClusterStatusPolicy(_ => HealthStatus.Unhealthy); + Assert.Equal(HealthStatus.Unhealthy, policy.Evaluate(MemberStatus.Up)); + } + + [Fact] + public async Task HealthCheck_NoActorSystem_ReturnsDegraded() + { + var provider = new ServiceCollection().BuildServiceProvider(); + var check = new AkkaClusterHealthCheck(provider, AkkaClusterStatusPolicy.Default); + + var result = await check.CheckHealthAsync(NewContext(check)); + + Assert.Equal(HealthStatus.Degraded, result.Status); + } + + private static HealthCheckContext NewContext(IHealthCheck check) => new() + { + Registration = new HealthCheckRegistration("akka-cluster", check, HealthStatus.Unhealthy, tags: null), + }; +} diff --git a/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.EntityFrameworkCore.Tests/DatabaseHealthCheckTests.cs b/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.EntityFrameworkCore.Tests/DatabaseHealthCheckTests.cs new file mode 100644 index 0000000..6cb81df --- /dev/null +++ b/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.EntityFrameworkCore.Tests/DatabaseHealthCheckTests.cs @@ -0,0 +1,133 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using ZB.MOM.WW.Health.EntityFrameworkCore; + +namespace ZB.MOM.WW.Health.EntityFrameworkCore.Tests; + +/// +/// Verifies against a real SQLite database (in-memory, +/// connection kept open) so the CanConnectAsync semantics exercise an actual provider: +/// reachable → Healthy, unopenable connection → Unhealthy (no throw escapes), a custom +/// that queries → Healthy, and a +/// throwing ProbeQuery → Unhealthy. Both the and +/// the scoped-TContext resolution paths are covered. +/// +public sealed class DatabaseHealthCheckTests +{ + /// A minimal context with one entity, used purely to drive provider behaviour. + private sealed class WidgetContext : DbContext + { + public WidgetContext(DbContextOptions options) : base(options) { } + + public DbSet Widgets => Set(); + } + + private sealed class Widget + { + public int Id { get; set; } + } + + private static HealthCheckContext NewContext() => new() + { + Registration = new HealthCheckRegistration( + "database", + sp => throw new InvalidOperationException("not used"), + HealthStatus.Unhealthy, + tags: null), + }; + + /// + /// Builds a provider whose is backed by the supplied open + /// SQLite connection (and creates the schema). When is true the + /// context is registered via AddDbContextFactory; otherwise via AddDbContext (scoped). + /// + private static IServiceProvider BuildProvider(SqliteConnection connection, bool useFactory) + { + connection.Open(); + + var services = new ServiceCollection(); + if (useFactory) + { + services.AddDbContextFactory(o => o.UseSqlite(connection)); + } + else + { + services.AddDbContext(o => o.UseSqlite(connection)); + } + + var provider = services.BuildServiceProvider(); + + using var scope = provider.CreateScope(); + scope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); + + return provider; + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ReachableContext_Healthy(bool useFactory) + { + using var connection = new SqliteConnection("DataSource=:memory:"); + var provider = BuildProvider(connection, useFactory); + + var check = new DatabaseHealthCheck(provider); + + var result = await check.CheckHealthAsync(NewContext(), CancellationToken.None); + + Assert.Equal(HealthStatus.Healthy, result.Status); + } + + [Fact] + public async Task UnopenableConnection_Unhealthy_NoThrow() + { + // Point the context at a file path that cannot be opened (parent directory does not exist). + var bogusPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "missing", "db.sqlite"); + + var services = new ServiceCollection(); + services.AddDbContext(o => o.UseSqlite($"DataSource={bogusPath};Mode=ReadWrite")); + var provider = services.BuildServiceProvider(); + + var check = new DatabaseHealthCheck(provider); + + var result = await check.CheckHealthAsync(NewContext(), CancellationToken.None); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + } + + [Fact] + public async Task CustomProbeQuery_RunsQuery_Healthy() + { + using var connection = new SqliteConnection("DataSource=:memory:"); + var provider = BuildProvider(connection, useFactory: true); + + var options = new DatabaseHealthCheckOptions + { + ProbeQuery = (ctx, ct) => ctx.Widgets.AsNoTracking().AnyAsync(ct), + }; + var check = new DatabaseHealthCheck(provider, options); + + var result = await check.CheckHealthAsync(NewContext(), CancellationToken.None); + + Assert.Equal(HealthStatus.Healthy, result.Status); + } + + [Fact] + public async Task ProbeQueryThrows_Unhealthy() + { + using var connection = new SqliteConnection("DataSource=:memory:"); + var provider = BuildProvider(connection, useFactory: false); + + var options = new DatabaseHealthCheckOptions + { + ProbeQuery = (_, _) => throw new InvalidOperationException("boom"), + }; + var check = new DatabaseHealthCheck(provider, options); + + var result = await check.CheckHealthAsync(NewContext(), CancellationToken.None); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + } +}