feat(redundancy): manual failover service — graceful Leave of the driver Primary

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-22 06:40:58 -04:00
parent 983310edbb
commit 69b697bc3e
4 changed files with 416 additions and 0 deletions
@@ -0,0 +1,54 @@
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy;
/// <summary>
/// A read-only view of the cluster's driver membership, as the manual-failover control sees it.
/// </summary>
/// <param name="PrimaryAddress">
/// The current driver Primary — the oldest Up <c>driver</c> member, the same node
/// <see cref="RedundancyStateActor.SelectDriverPrimary"/> names — or <see langword="null"/> when
/// there is no Up driver member at all.
/// </param>
/// <param name="DriverAddresses">
/// Every Up <c>driver</c> member, oldest first. A manual failover needs at least two: with one
/// there is nothing to fail over to.
/// </param>
public sealed record ManualFailoverSnapshot(string? PrimaryAddress, IReadOnlyList<string> DriverAddresses)
{
/// <summary>Whether a failover can be performed — i.e. a peer exists to take over.</summary>
public bool CanFailOver => DriverAddresses.Count >= 2;
}
/// <summary>
/// Operator-initiated, graceful swap of the driver Primary.
/// </summary>
/// <remarks>
/// <para>
/// The mechanism is a cluster <b>Leave</b> of the current Primary, never a <c>Down</c>. The
/// leaving node hands its singletons over through the normal cluster-leave phases,
/// <c>CoordinatedShutdown</c> runs, <c>ActorSystemTerminationWatchdog</c> exits the process,
/// the service supervisor restarts it, and it rejoins as the youngest member — so the
/// survivor becomes the oldest Up driver member and therefore Primary. ServiceLevel 250
/// moves with it and OPC UA clients re-select.
/// </para>
/// <para>
/// <b>Mesh-scope caveat.</b> Until the per-cluster mesh work lands
/// (<c>docs/plans/2026-07-21-per-cluster-mesh-design.md</c>) the Primary is elected once per
/// Akka mesh, not per application <c>Cluster</c> row. On a fleet running several application
/// clusters in one mesh this acts on the whole mesh's Primary. The UI says so.
/// </para>
/// </remarks>
public interface IManualFailoverService
{
/// <summary>Reads the current driver membership from live cluster state.</summary>
ManualFailoverSnapshot GetSnapshot();
/// <summary>
/// Gracefully removes the current driver Primary from the cluster so its peer takes over.
/// </summary>
/// <param name="actor">The authenticated identity performing the failover; recorded in the audit event.</param>
/// <returns>
/// The address of the node asked to leave, or <see langword="null"/> when the failover was
/// refused because no driver peer exists (in which case nothing was changed and nothing audited).
/// </returns>
Task<string?> FailOverDriverPrimaryAsync(string actor);
}
@@ -0,0 +1,128 @@
using System.Text.Json;
using Akka.Actor;
using Akka.Cluster;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.Audit;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy;
/// <summary>
/// Default <see cref="IManualFailoverService"/> — a graceful cluster <c>Leave</c> of the oldest Up
/// <c>driver</c> member. See the interface for the full behaviour and the mesh-scope caveat.
/// </summary>
public sealed class ManualFailoverService : IManualFailoverService
{
/// <summary>The audit <see cref="AuditEvent.Action"/> stamped onto every manual failover.</summary>
public const string AuditAction = "cluster.manual-failover";
/// <summary>The audit <see cref="AuditEvent.Category"/> stamped onto every manual failover.</summary>
public const string AuditCategory = "Redundancy";
private readonly Func<ActorSystem> _system;
private readonly IAuditWriter _audit;
private readonly ILogger<ManualFailoverService> _log;
/// <summary>
/// Creates the service. The ActorSystem is taken as a factory, never as a constructed instance:
/// this is registered before Akka's hosted service has built the system, so resolving eagerly
/// would race startup (the same lazy-accessor pattern <c>ActorSystemTerminationWatchdog</c> uses).
/// </summary>
/// <param name="system">Lazy accessor for the node's ActorSystem.</param>
/// <param name="audit">The shared audit seam; a failover is recorded before it is performed.</param>
/// <param name="log">Logger for the failover decision.</param>
public ManualFailoverService(Func<ActorSystem> system, IAuditWriter audit, ILogger<ManualFailoverService> log)
{
_system = system ?? throw new ArgumentNullException(nameof(system));
_audit = audit ?? throw new ArgumentNullException(nameof(audit));
_log = log ?? throw new ArgumentNullException(nameof(log));
}
/// <inheritdoc/>
public ManualFailoverSnapshot GetSnapshot() => SnapshotCore(_system());
/// <inheritdoc/>
public async Task<string?> FailOverDriverPrimaryAsync(string actor)
{
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
var system = _system();
// Dry run first so the peer guard is evaluated before anything is audited: a refused
// failover changed nothing and must not leave an audit row implying it did.
var target = FailOverCore(system, dryRun: true);
if (target is null)
{
_log.LogWarning(
"Manual failover refused for {Actor}: fewer than two Up driver members — there is no peer to take over.",
actor);
return null;
}
await _audit.WriteAsync(new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = actor,
Action = AuditAction,
Category = AuditCategory,
SourceNode = target.ToString(),
Outcome = AuditOutcome.Success,
DetailsJson = JsonSerializer.Serialize(new
{
target = target.ToString(),
driverMembers = SnapshotCore(system).DriverAddresses,
}),
}).ConfigureAwait(false);
_log.LogWarning(
"Manual failover requested by {Actor}: asking the driver Primary {Target} to leave the cluster. "
+ "It hands over its singletons, shuts down, and is restarted by its supervisor as the youngest "
+ "member; its peer becomes Primary and advertises the primary ServiceLevel.",
actor,
target);
FailOverCore(system, dryRun: false);
return target.ToString();
}
/// <summary>
/// Selects — and, unless <paramref name="dryRun"/>, gracefully removes — the driver Primary.
/// </summary>
/// <param name="system">The ActorSystem whose cluster is acted on.</param>
/// <param name="dryRun">When <see langword="true"/>, selects the target without leaving.</param>
/// <returns>The target address, or <see langword="null"/> when fewer than two Up driver members exist.</returns>
public static Address? FailOverCore(ActorSystem system, bool dryRun = false)
{
ArgumentNullException.ThrowIfNull(system);
var cluster = Akka.Cluster.Cluster.Get(system);
// Intentionally identical to RedundancyStateActor.SelectDriverPrimary (oldest Up driver,
// Member.AgeOrdering) — the node acted on must be exactly the one the election names, or a
// failover would remove a node that was not Primary and change nothing. Pinned by
// ManualFailoverServiceTests.Parity_with_SelectDriverPrimary.
var drivers = OrderedDrivers(cluster.State.Members);
// A failover needs somewhere to fail over TO. With a single driver member, leaving would be
// a shutdown dressed up as a failover.
if (drivers.Count < 2) return null;
var primary = drivers[0];
if (!dryRun) cluster.Leave(primary.Address);
return primary.Address;
}
private static ManualFailoverSnapshot SnapshotCore(ActorSystem system)
{
var drivers = OrderedDrivers(Akka.Cluster.Cluster.Get(system).State.Members);
return new ManualFailoverSnapshot(
drivers.FirstOrDefault()?.Address.ToString(),
drivers.Select(m => m.Address.ToString()).ToList());
}
private static List<Member> OrderedDrivers(IEnumerable<Member> members) => members
.Where(m => m.Status == MemberStatus.Up && m.Roles.Contains(RedundancyStateActor.DriverRole))
.OrderBy(m => m, Member.AgeOrdering)
.ToList();
}