refactor(health): adopt the shared active-node check (Health 0.3.0)
ClusterPrimaryHealthCheck was written three days' worth of debugging ago because the shared ActiveNodeHealthCheck selected by RoleLeader and reported Healthy for any node lacking the role. Health 0.3.0 fixes the shared check — oldest Up member, role-preference scoping, Unhealthy when the node owns no active work — so the private copy is now duplication rather than a workaround, and it is deleted. RolePreference [admin, driver] reproduces exactly what it did: a fused central node answers for admin (where the singletons and the AdminUI are pinned), a driver-only site node answers for driver. SelectOldestUpMemberOfRole now delegates to the shared ClusterActiveNode instead of re-implementing the age ordering. This is the point of the change rather than a tidy-up: the redundancy snapshot drives the OPC UA ServiceLevel 250/240 split while the health tier drives Traefik's admin routing, so two copies of "oldest Up member of a role" would be two chances to advertise one node as authoritative while gating the data plane on another. ControlPlane takes a ZB.MOM.WW.Health.Akka reference for it; the layering trade-off is recorded at the PackageReference. Behaviour note: a node carrying neither admin nor driver now answers 503 on the active tier instead of 200. That case is reachable — RoleParser admits dev-only and cluster-role-only nodes — and 503 is correct, since such a node owns no active work and must not be in an active-tier pool. No docker-dev node is affected: every rig node is admin+driver or driver-only. Tests replaced in kind. The rule itself is now pinned in the library against a real two-node cluster; what stays OtOpcUa's own decision is the wiring, so ActiveTierRegistrationTests pins which check is on the active tag, that it is registered on driver-only nodes too, and that it is not on the ready tier. Verified: Host.Tests 25/25, ControlPlane redundancy 15/15.
This commit is contained in:
@@ -2,6 +2,7 @@ using Akka.Actor;
|
||||
using Akka.Cluster;
|
||||
using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Akka.Event;
|
||||
using ZB.MOM.WW.Health.Akka;
|
||||
using ZB.MOM.WW.OtOpcUa.Cluster;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
@@ -152,22 +153,28 @@ public sealed class RedundancyStateActor : ReceiveActor, IWithTimers
|
||||
/// <param name="role">The cluster role to scope the selection to.</param>
|
||||
/// <returns>The oldest Up member's address for that role, or <c>null</c> when there is none.</returns>
|
||||
/// <remarks>
|
||||
/// The age-ordering rationale in <see cref="SelectDriverPrimary"/> applies verbatim to any role:
|
||||
/// oldest, never <c>RoleLeader</c>. Factored out so the rule has exactly one implementation —
|
||||
/// the health tier that reports which node is active needs the same answer the redundancy
|
||||
/// snapshot does, and two copies of "oldest Up member of a role" would be two chances to
|
||||
/// silently disagree about who is in charge.
|
||||
/// <para>
|
||||
/// The age-ordering rationale in <see cref="SelectDriverPrimary"/> applies verbatim to any
|
||||
/// role: oldest, never <c>RoleLeader</c>. The rule itself lives in the shared
|
||||
/// <see cref="ClusterActiveNode"/> (<c>ZB.MOM.WW.Health.Akka</c> 0.3.0) so it has exactly
|
||||
/// one implementation family-wide — this method is the control plane's entry point into it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Delegating rather than re-implementing is what keeps the redundancy snapshot and the
|
||||
/// <c>/health/active</c> tier structurally in agreement. They answer for different
|
||||
/// consumers — the OPC UA <c>ServiceLevel</c> 250/240 split reads this election, while an
|
||||
/// orchestrator routes admin traffic by the health tier — so a divergence would advertise
|
||||
/// one node as authoritative while gating the data plane on another. Both apps in the
|
||||
/// family previously kept private copies of this rule, and both got it wrong in a
|
||||
/// different way.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static Address? SelectOldestUpMemberOfRole(IEnumerable<Member> members, string role)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(members);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(role);
|
||||
|
||||
return members
|
||||
.Where(m => m.Status == MemberStatus.Up && m.Roles.Contains(role))
|
||||
.OrderBy(m => m, Member.AgeOrdering)
|
||||
.FirstOrDefault()
|
||||
?.Address;
|
||||
return ClusterActiveNode.OldestUpMember(members, role)?.Address;
|
||||
}
|
||||
|
||||
private IReadOnlyList<NodeRedundancyState> BuildSnapshot()
|
||||
|
||||
@@ -19,6 +19,14 @@
|
||||
Google.Protobuf + Grpc.Core.Api flow transitively from Commons (the generated client). -->
|
||||
<PackageReference Include="Grpc.Net.Client"/>
|
||||
<PackageReference Include="ZB.MOM.WW.Audit"/>
|
||||
<!-- For ClusterActiveNode — the shared "oldest Up member of role X" primitive. The control
|
||||
plane and the /health/active tier must give the SAME answer for which node is in charge,
|
||||
or the tier reports one node active while the Primary-gated data plane runs on another;
|
||||
delegating here is what makes that structural rather than a convention. It lives in
|
||||
Health.Akka because that is the family's Akka-cluster utility package (it already hosts
|
||||
AkkaClusterStatusPolicy and an endpoint gate); a dedicated cluster package would be a
|
||||
better home if a third such primitive ever appears. -->
|
||||
<PackageReference Include="ZB.MOM.WW.Health.Akka"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using ZB.MOM.WW.OtOpcUa.Cluster;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Health;
|
||||
|
||||
/// <summary>
|
||||
/// The active tier's answer to "is this the node in charge of this cluster?" — Healthy (200) on the
|
||||
/// one node that owns the active work for its mesh, Unhealthy (503) on its partner.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Replaces the shared <c>ActiveNodeHealthCheck(role: "admin")</c>, which answered a
|
||||
/// different question and got it wrong twice. It was scoped to the <c>admin</c> role and
|
||||
/// returns Healthy for any node that lacks that role, so all four driver-only site nodes
|
||||
/// reported themselves active; and it selected by <c>RoleLeader</c>, the
|
||||
/// <i>lowest-addressed</i> member, which is not where Akka places singletons. See
|
||||
/// <c>lmxopcua#494</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The rule here is one sentence: <b>this node is active iff it is the oldest Up member
|
||||
/// carrying its own active role</b>, where the active role is <c>admin</c> when the node has
|
||||
/// it and <c>driver</c> otherwise. That single rule serves both consumers correctly:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description>
|
||||
/// A fused admin node answers for the <c>admin</c> role, so Traefik pins the AdminUI to
|
||||
/// the node actually hosting the cluster singletons — which
|
||||
/// <c>ClusterSingletonManager</c> places on the <b>oldest</b> member, not the role
|
||||
/// leader. The two diverge after any restart.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// A driver-only site node answers for the <c>driver</c> role, which is exactly
|
||||
/// <see cref="RedundancyStateActor.SelectDriverPrimary"/> — the same election that
|
||||
/// drives <c>IsDriverPrimary</c> and the OPC UA <c>ServiceLevel</c> 250/240 split. So
|
||||
/// the tier and the ServiceLevel can no longer disagree about which node is Primary.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Scoping is per-mesh for free: after per-cluster mesh Phase 6 a node's
|
||||
/// <c>ClusterState.Members</c> contains only its own application <c>Cluster</c>, so
|
||||
/// "oldest Up member" is already "oldest Up member of this <c>Cluster</c>" — exactly one
|
||||
/// node answers 200 per <c>Cluster</c>, by construction rather than by filtering.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Startup-safe, matching the shared check it replaces: before the <see cref="ActorSystem"/>
|
||||
/// or the cluster is available the result is Degraded, not Unhealthy, so a node that is
|
||||
/// merely still booting is not reported as a failed standby.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ClusterPrimaryHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
|
||||
/// <summary>Initializes a new <see cref="ClusterPrimaryHealthCheck"/>.</summary>
|
||||
/// <param name="serviceProvider">
|
||||
/// The application service provider. The <see cref="ActorSystem"/> is resolved lazily so the
|
||||
/// check is startup-safe.
|
||||
/// </param>
|
||||
public ClusterPrimaryHealthCheck(IServiceProvider serviceProvider) =>
|
||||
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||
|
||||
/// <summary>
|
||||
/// The role whose oldest Up member owns the active work for <paramref name="selfRoles"/>.
|
||||
/// </summary>
|
||||
/// <param name="selfRoles">The local member's cluster roles.</param>
|
||||
/// <returns>
|
||||
/// <see cref="RoleParser.Admin"/> when the node carries it, otherwise
|
||||
/// <see cref="RoleParser.Driver"/> when it carries that, otherwise <c>null</c> for a node in
|
||||
/// neither role.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// Admin wins on a fused node because that is the role the cluster singletons — and the AdminUI
|
||||
/// that Traefik routes to — are pinned to.
|
||||
/// </remarks>
|
||||
public static string? ActiveRoleFor(IEnumerable<string> selfRoles)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(selfRoles);
|
||||
|
||||
var roles = selfRoles as IReadOnlyCollection<string> ?? selfRoles.ToList();
|
||||
|
||||
if (roles.Contains(RoleParser.Admin))
|
||||
return RoleParser.Admin;
|
||||
|
||||
return roles.Contains(RoleParser.Driver) ? RoleParser.Driver : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var system = _serviceProvider.GetService<ActorSystem>();
|
||||
if (system is null)
|
||||
return Task.FromResult(HealthCheckResult.Degraded("ActorSystem not yet available."));
|
||||
|
||||
Member self;
|
||||
IEnumerable<Member> members;
|
||||
try
|
||||
{
|
||||
var cluster = Akka.Cluster.Cluster.Get(system);
|
||||
self = cluster.SelfMember;
|
||||
members = cluster.State.Members;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return Task.FromResult(HealthCheckResult.Degraded("Akka cluster state not yet accessible.", ex));
|
||||
}
|
||||
|
||||
var role = ActiveRoleFor(self.Roles);
|
||||
if (role is null)
|
||||
{
|
||||
// Neither admin nor driver. RoleParser will not produce such a node today, so this is a
|
||||
// guard rather than a supported topology — reported Healthy because the tier has no
|
||||
// opinion about a node that owns no active work, not because the node is in charge.
|
||||
return Task.FromResult(HealthCheckResult.Healthy("Node carries neither the admin nor the driver role."));
|
||||
}
|
||||
|
||||
var primary = RedundancyStateActor.SelectOldestUpMemberOfRole(members, role);
|
||||
|
||||
var data = new Dictionary<string, object>(StringComparer.Ordinal)
|
||||
{
|
||||
["activeRole"] = role,
|
||||
["selfAddress"] = self.Address.ToString(),
|
||||
};
|
||||
|
||||
if (primary is not null)
|
||||
data["primary"] = primary.ToString();
|
||||
|
||||
if (primary is null)
|
||||
{
|
||||
// No Up member holds the role — during a cold start, or while every holder is Joining or
|
||||
// Leaving. Nobody is active, so nobody may claim to be.
|
||||
return Task.FromResult(HealthCheckResult.Unhealthy(
|
||||
$"No Up member currently carries the '{role}' role.", exception: null, data));
|
||||
}
|
||||
|
||||
return Task.FromResult(primary == self.Address
|
||||
? HealthCheckResult.Healthy($"Primary for role '{role}' (oldest Up member).", data)
|
||||
: HealthCheckResult.Unhealthy($"Standby: '{role}' primary is {primary}.", exception: null, data));
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ using ZB.MOM.WW.Health;
|
||||
using ZB.MOM.WW.Health.Akka;
|
||||
using ZB.MOM.WW.Health.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.LocalDb.Replication;
|
||||
using ZB.MOM.WW.OtOpcUa.Cluster;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
@@ -53,15 +54,33 @@ public static class HealthEndpoints
|
||||
failureStatus: null,
|
||||
tags: new[] { ZbHealthTags.Ready, ZbHealthTags.Active },
|
||||
args: AkkaClusterStatusPolicy.OtOpcUaCompat)
|
||||
// "Is this node in charge of its mesh?" — the question the active tier is supposed to
|
||||
// answer, and the one the shared ActiveNodeHealthCheck(role: "admin") did not. That check
|
||||
// returned Healthy for any node lacking the admin role, so every driver-only site node
|
||||
// called itself active, and it selected by RoleLeader (lowest address) rather than by
|
||||
// age, which is not where Akka places singletons. See lmxopcua#494.
|
||||
.AddTypeActivatedCheck<ClusterPrimaryHealthCheck>(
|
||||
// "Is this node in charge of its mesh?" — Healthy on the one node that owns the active
|
||||
// work for its Cluster, Unhealthy (503) on its partner, so Traefik pins the AdminUI to the
|
||||
// node actually hosting the cluster singletons.
|
||||
//
|
||||
// Role preference admin-then-driver: a fused central node answers for `admin` (the role
|
||||
// the singletons and the AdminUI are pinned to), while a driver-only site node answers for
|
||||
// `driver` — which is exactly RedundancyStateActor.SelectDriverPrimary, the election behind
|
||||
// IsDriverPrimary and the OPC UA ServiceLevel 250/240 split. Both now route through the
|
||||
// shared ClusterActiveNode, so the tier and the ServiceLevel cannot disagree.
|
||||
//
|
||||
// Scoping is per-mesh for free: after per-cluster mesh Phase 6 a node's ClusterState
|
||||
// contains only its own application Cluster, so "oldest Up member" is already "oldest Up
|
||||
// member of this Cluster" — exactly one node answers 200 per Cluster, by construction.
|
||||
//
|
||||
// Before Health 0.3.0 this was ActiveNodeHealthCheck(role: "admin"), which answered a
|
||||
// different question and got it wrong twice: it returned Healthy for any node lacking the
|
||||
// admin role, so every driver-only site node called itself active, and it selected by
|
||||
// RoleLeader (lowest address) rather than by age, which is not where Akka places
|
||||
// singletons. See lmxopcua#494.
|
||||
.AddTypeActivatedCheck<ActiveNodeHealthCheck>(
|
||||
"cluster-primary",
|
||||
failureStatus: null,
|
||||
tags: new[] { ZbHealthTags.Active })
|
||||
tags: new[] { ZbHealthTags.Active },
|
||||
args: new ActiveNodeHealthCheckOptions
|
||||
{
|
||||
RolePreference = new[] { RoleParser.Admin, RoleParser.Driver },
|
||||
})
|
||||
// Registered on every node regardless of role (unlike the admin-only configdb probe above):
|
||||
// the check itself resolves ISyncStatus optionally and reports Healthy when LocalDb is absent
|
||||
// (admin-only graphs) or replication is default-OFF, so a plain node is never degraded by it.
|
||||
|
||||
Reference in New Issue
Block a user