docs(health): oldest-Up-member is the active-node rule (0.3.0)

Rewrites the active-node sections of the spec and shared contract, which
described the leader-based design that both consumers rejected.

- SPEC §2.3 replaced: the rule, the ordered RolePreference, why a node holding
  none of the preferred roles is Unhealthy rather than Healthy, and the data
  payload. §4 (IActiveNodeGate) and the tier table follow.
- SPEC §6 marked historical: it still tells adopters to use
  ActiveNodeHealthCheck(role: "admin") and AkkaActiveNodeGate, which is the
  advice that produced the defect. Kept for provenance, annotated so nobody
  follows it again.
- GAPS: gaps L1/L2 recorded as closed the wrong way, reopened, then closed at
  0.3.0 — including the part worth remembering, that both apps adopted the
  shared check as specified, hit it live, and reverted to private copies, so the
  surface reached zero consumers by being avoided rather than unused.
- Index rows updated with the consumer-visible changes and both app branches.
This commit is contained in:
Joseph Doherty
2026-07-24 13:24:30 -04:00
parent a61e041e58
commit 6a4fa220de
5 changed files with 139 additions and 49 deletions
@@ -10,7 +10,7 @@ exist: OtOpcUa `Health/` (three-tier + probes), ScadaBridge `Health/` (inline pr
```
ZB.MOM.WW.Health # core: tier convention, response writer, IActiveNodeGate, GrpcDependencyHealthCheck
ZB.MOM.WW.Health.Akka # AkkaClusterHealthCheck, ActiveNodeHealthCheck, AkkaActiveNodeGate
ZB.MOM.WW.Health.Akka # AkkaClusterHealthCheck, ClusterActiveNode, ActiveNodeHealthCheck, AkkaActiveNodeGate
ZB.MOM.WW.Health.EntityFrameworkCore # DatabaseHealthCheck<TContext>
```
@@ -79,7 +79,7 @@ public static class ZbHealthWriter
public static Task WriteJsonAsync(HttpContext context, HealthReport report);
}
/// Single-property seam: is this node the active/leader node?
/// Single-property seam: is this node the active node (oldest Up member)?
/// Attach to route groups via RequireActiveNode(). Implement with AkkaActiveNodeGate (Health.Akka)
/// or a project-specific implementation for non-Akka nodes.
public interface IActiveNodeGate
@@ -162,49 +162,77 @@ public sealed class AkkaClusterStatusPolicy
public static AkkaClusterStatusPolicy OtOpcUaCompat { get; }
}
/// Checks whether this node is the designated leader / active node.
/// Optional role parameter scopes the check to nodes carrying that role.
/// Register to tag ZbHealthTags.Active.
/// THE family-wide definition of "the active node": the OLDEST Up member, optionally scoped to a
/// role — the member ClusterSingletonManager places singletons on. Never cluster leadership, which is
/// address-ordered and diverges from singleton placement permanently after any restart (0.3.0).
public static class ClusterActiveNode
{
/// The oldest Up member, optionally scoped to a role. Null when none is Up.
public static Member? OldestUpMember(IEnumerable<Member> members, string? role = null);
public static Member? OldestUpMember(Cluster cluster, string? role = null);
/// True when the local member is Up, carries the role (if given), and no eligible member is
/// older. Identity compares UniqueAddress, so a node restarted on the same host:port is a
/// different member during the overlap.
public static bool SelfIsActive(Cluster cluster, string? role = null);
/// The first role in rolePreference that selfRoles carries, else null. Lets a fused node answer
/// for the role its singletons are pinned to (e.g. ["admin", "driver"]).
public static string? ResolveActiveRole(IEnumerable<string> selfRoles, IReadOnlyList<string> rolePreference);
}
/// Configures which role's active node a node competes to be.
public sealed class ActiveNodeHealthCheckOptions
{
/// Candidate roles in descending precedence. Empty (default) = unscoped: oldest Up member of the
/// whole cluster.
public IReadOnlyList<string> RolePreference { get; init; }
/// Status when RolePreference is non-empty and the node carries none of those roles — it owns no
/// active work. Defaults to Unhealthy: the active tier exists so a load balancer can pick exactly
/// one node, and "not applicable => Healthy" made the tier answer 200 on every node (lmxopcua#494).
public HealthStatus NoActiveRoleStatus { get; init; }
}
/// Active-tier probe: Healthy on the one node in charge, Unhealthy (503) on its standby.
/// Register to tag ZbHealthTags.Active. Selects via ClusterActiveNode (oldest Up member).
/// Result data carries selfAddress, activeRole (when scoped) and activeNode, so a standby reports
/// WHO is active.
/// <remarks>
/// The ActorSystem is resolved lazily from the service provider. If the ActorSystem is not yet
/// available (e.g. during startup before Akka is initialised), the check returns Degraded rather
/// than throwing. This makes the check startup-safe.
/// The ActorSystem is resolved lazily from the service provider. If the ActorSystem or cluster is not
/// yet available (e.g. during startup before Akka is initialised), the check returns Degraded rather
/// than throwing. This makes the check startup-safe. A reachable cluster with no Up member in scope
/// is Unhealthy, not Degraded — a cluster that never forms must not sit at 200.
/// </remarks>
public sealed class ActiveNodeHealthCheck : IHealthCheck
{
/// Role-less constructor: Healthy = node is Up AND cluster leader (ScadaBridge ActiveNode pattern).
/// Returns Degraded when ActorSystem/cluster is not yet ready.
/// <param name="serviceProvider">
/// The application service provider. ActorSystem is resolved lazily so the check is
/// startup-safe: if no ActorSystem is registered yet the result is Degraded.
/// </param>
/// Unscoped: the active node is the oldest Up member of the whole cluster.
public ActiveNodeHealthCheck(IServiceProvider serviceProvider);
/// Role-filtered constructor: Healthy = (node lacks the role) OR (node carries role AND is role-singleton leader).
/// Degraded = node carries role but is not the role-singleton leader (OtOpcUa AdminRoleLeader pattern).
/// Returns Degraded when ActorSystem/cluster is not yet ready.
/// <param name="serviceProvider">
/// The application service provider. ActorSystem is resolved lazily so the check is
/// startup-safe: if no ActorSystem is registered yet the result is Degraded.
/// </param>
/// Role-scoped: the active node is the oldest Up member carrying `role`.
public ActiveNodeHealthCheck(IServiceProvider serviceProvider, string role);
/// Configured, including a multi-role preference order.
public ActiveNodeHealthCheck(IServiceProvider serviceProvider, ActiveNodeHealthCheckOptions options);
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default);
}
/// IActiveNodeGate implementation that computes IsActiveNode directly from the ActorSystem
/// (SelfMember Up + cluster leader), null-guarded for startup safety.
/// IActiveNodeGate implementation that computes IsActiveNode directly from the ActorSystem via
/// ClusterActiveNode (oldest Up member, optional role scope), null-guarded for startup safety.
/// Shares its rule with ActiveNodeHealthCheck, so a gated endpoint and /health/active agree.
/// Register as a singleton. Does NOT resolve ActiveNodeHealthCheck from DI.
public sealed class AkkaActiveNodeGate : IActiveNodeGate
{
/// <param name="serviceProvider">
/// The application service provider. ActorSystem is resolved lazily; if not yet available
/// IsActiveNode returns false (safe default during startup).
/// The gate checks SelfMember.Status == Up AND cluster.State.Leader == self.Address directly.
/// The gate delegates to ClusterActiveNode.SelfIsActive(cluster, role) directly.
/// </param>
public AkkaActiveNodeGate(IServiceProvider serviceProvider);
/// <param name="role">Optional role scope; null (default) considers every Up member.</param>
public AkkaActiveNodeGate(IServiceProvider serviceProvider, string? role = null);
public bool IsActiveNode { get; }
}