feat(health.akka): cluster health check with configurable status policy
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Table-driven tests for the pure status-mapping function inside <see cref="AkkaClusterStatusPolicy"/>.
|
||||
/// The two presets (<see cref="AkkaClusterStatusPolicy.Default"/> and
|
||||
/// <see cref="AkkaClusterStatusPolicy.OtOpcUaCompat"/>) are the convergence targets for ScadaBridge
|
||||
/// and OtOpcUa respectively; every <see cref="MemberStatus"/> is exercised so a drift in either
|
||||
/// preset fails loudly. Also covers the startup-safety null-guard on <see cref="AkkaClusterHealthCheck"/>.
|
||||
/// </summary>
|
||||
public sealed class AkkaClusterStatusPolicyTests
|
||||
{
|
||||
public static IEnumerable<object[]> 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<object[]> 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),
|
||||
};
|
||||
}
|
||||
+133
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="DatabaseHealthCheck{TContext}"/> against a real SQLite database (in-memory,
|
||||
/// connection kept open) so the <c>CanConnectAsync</c> semantics exercise an actual provider:
|
||||
/// reachable → Healthy, unopenable connection → Unhealthy (no throw escapes), a custom
|
||||
/// <see cref="DatabaseHealthCheckOptions{TContext}.ProbeQuery"/> that queries → Healthy, and a
|
||||
/// throwing <c>ProbeQuery</c> → Unhealthy. Both the <see cref="IDbContextFactory{TContext}"/> and
|
||||
/// the scoped-<c>TContext</c> resolution paths are covered.
|
||||
/// </summary>
|
||||
public sealed class DatabaseHealthCheckTests
|
||||
{
|
||||
/// <summary>A minimal context with one entity, used purely to drive provider behaviour.</summary>
|
||||
private sealed class WidgetContext : DbContext
|
||||
{
|
||||
public WidgetContext(DbContextOptions<WidgetContext> options) : base(options) { }
|
||||
|
||||
public DbSet<Widget> Widgets => Set<Widget>();
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds a provider whose <typeparamref name="WidgetContext"/> is backed by the supplied open
|
||||
/// SQLite connection (and creates the schema). When <paramref name="useFactory"/> is true the
|
||||
/// context is registered via <c>AddDbContextFactory</c>; otherwise via <c>AddDbContext</c> (scoped).
|
||||
/// </summary>
|
||||
private static IServiceProvider BuildProvider(SqliteConnection connection, bool useFactory)
|
||||
{
|
||||
connection.Open();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
if (useFactory)
|
||||
{
|
||||
services.AddDbContextFactory<WidgetContext>(o => o.UseSqlite(connection));
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddDbContext<WidgetContext>(o => o.UseSqlite(connection));
|
||||
}
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
using var scope = provider.CreateScope();
|
||||
scope.ServiceProvider.GetRequiredService<WidgetContext>().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<WidgetContext>(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<WidgetContext>(o => o.UseSqlite($"DataSource={bogusPath};Mode=ReadWrite"));
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var check = new DatabaseHealthCheck<WidgetContext>(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<WidgetContext>
|
||||
{
|
||||
ProbeQuery = (ctx, ct) => ctx.Widgets.AsNoTracking().AnyAsync(ct),
|
||||
};
|
||||
var check = new DatabaseHealthCheck<WidgetContext>(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<WidgetContext>
|
||||
{
|
||||
ProbeQuery = (_, _) => throw new InvalidOperationException("boom"),
|
||||
};
|
||||
var check = new DatabaseHealthCheck<WidgetContext>(provider, options);
|
||||
|
||||
var result = await check.CheckHealthAsync(NewContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(HealthStatus.Unhealthy, result.Status);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user