71379816e7
Moves the alarm store-and-forward buffer out of its own alarm-historian.db and into the node's consolidated LocalDb, where it replicates to the redundant pair peer. A node that dies holding undelivered alarm history no longer takes it to the grave. Tasks 2 and 3 land together, as the plan anticipated. They are not separable: the gate is a constructor argument of the rewritten sink, and a commit that replicated the queue without gating the drain would be a commit in which both nodes of a pair deliver every alarm event, continuously. That drain gate is the load-bearing part of the change, and the recon explains why it is new work rather than a refinement. Exactly-once delivery across a pair is enforced today on the ENQUEUE side, by HistorianAdapterActor: only the Primary enqueues, so the Secondary's queue is empty and its ungated drain has nothing to send. Replicating the table destroys that invariant. The gate is a Func<bool> the caller supplies, because the drain runs on a timer the sink owns rather than on a mailbox, and Core.AlarmHistorian cannot reference PrimaryGatePolicy in Runtime. Runtime supplies it via a new IRedundancyRoleView singleton that DriverHostActor publishes its Primary-gate verdict to -- the same verdict the inbound-write and native-ack gates use, so there is no second notion of am-I-the-Primary to drift. Two failure modes are deliberately closed: - The view is seeded OPEN, matching the policy's own answer for an unknown role with no driver peer. A deployment that runs no redundancy never publishes to it, and defaulting closed would silently stop its alarm history forever. - A gate that throws is read as not-now, never as permission, and a closed gate reports the new HistorianDrainState.NotPrimary rather than Idle. A Secondary's rising queue is supposed to look different from a stalled drain, and if BOTH nodes report NotPrimary the pair is misconfigured and says so instead of quietly filling toward the capacity ceiling. Row ids are a hash of the payload rather than fresh GUIDs. Both adapters accept the same fanned transition in the window before the first redundancy snapshot arrives, and under last-writer-wins an equal key converges those two accepts into one row instead of duplicating them. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
133 lines
5.2 KiB
C#
133 lines
5.2 KiB
C#
using Akka.Actor;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Redundancy;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
|
|
|
/// <summary>
|
|
/// The bridge that carries the Primary-gate decision out of the actor and into
|
|
/// <see cref="IRedundancyRoleView"/>, where the alarm store-and-forward drain can read it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The drain runs on a timer owned by the sink, not on a mailbox, so it cannot receive
|
|
/// <see cref="RedundancyStateChanged"/> itself — but it must be Primary-scoped, because
|
|
/// the queue it drains replicates to the pair peer and two draining nodes would deliver
|
|
/// every alarm event twice.
|
|
/// </para>
|
|
/// <para>
|
|
/// These cases mirror <see cref="DriverHostActorPrimaryGateTests"/>'s matrix on purpose:
|
|
/// the view must reach exactly the same verdict as the inbound-write and native-ack gates,
|
|
/// because a second, subtly different notion of "am I the Primary" is how a pair ends up
|
|
/// with one node writing and neither draining.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class DriverHostActorRoleViewTests : RuntimeActorTestBase
|
|
{
|
|
private static readonly NodeId TestNode = NodeId.Parse("driver-roleview-test");
|
|
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
|
|
|
|
/// <summary>
|
|
/// Before any snapshot arrives the view must read open, not closed.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A deployment that runs no redundancy never publishes here at all. Defaulting closed
|
|
/// would silently stop its alarm history forever — the exact failure the drain gate exists
|
|
/// to avoid causing.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void An_unpublished_view_reads_as_primary()
|
|
{
|
|
new RedundancyRoleView().ShouldServiceAsPrimary.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Secondary_role_closes_the_view()
|
|
{
|
|
var view = SpawnAndTellRole(RedundancyRole.Secondary, driverMemberCount: 2);
|
|
|
|
AwaitAssert(() => view.ShouldServiceAsPrimary.ShouldBeFalse(), duration: Timeout);
|
|
}
|
|
|
|
[Fact]
|
|
public void Primary_role_opens_the_view()
|
|
{
|
|
var view = SpawnAndTellRole(RedundancyRole.Primary, driverMemberCount: 2);
|
|
|
|
AwaitAssert(() => view.ShouldServiceAsPrimary.ShouldBeTrue(), duration: Timeout);
|
|
}
|
|
|
|
/// <summary>Unknown role + a real driver peer ⇒ closed, matching the write gate's default-DENY.</summary>
|
|
[Fact]
|
|
public void Unknown_role_with_a_driver_peer_closes_the_view()
|
|
{
|
|
// A snapshot that never names this node is how the role stays unknown in practice — the
|
|
// documented identity-mismatch shape, not a contrived case.
|
|
var view = SpawnAndTellForeignSnapshot(driverMemberCount: 2);
|
|
|
|
AwaitAssert(() => view.ShouldServiceAsPrimary.ShouldBeFalse(), duration: Timeout);
|
|
}
|
|
|
|
/// <summary>Unknown role + no driver peer ⇒ open, preserving the single-node posture.</summary>
|
|
[Fact]
|
|
public void Unknown_role_without_a_driver_peer_leaves_the_view_open()
|
|
{
|
|
var view = SpawnAndTellForeignSnapshot(driverMemberCount: 1);
|
|
|
|
AwaitAssert(() => view.ShouldServiceAsPrimary.ShouldBeTrue(), duration: Timeout);
|
|
}
|
|
|
|
/// <summary>A demotion must move the view, not just an initial promotion.</summary>
|
|
[Fact]
|
|
public void A_demotion_closes_a_previously_open_view()
|
|
{
|
|
var view = new RedundancyRoleView();
|
|
var host = SpawnHost(view, driverMemberCount: 2);
|
|
|
|
host.Tell(Snapshot(TestNode, RedundancyRole.Primary));
|
|
AwaitAssert(() => view.ShouldServiceAsPrimary.ShouldBeTrue(), duration: Timeout);
|
|
|
|
host.Tell(Snapshot(TestNode, RedundancyRole.Secondary));
|
|
AwaitAssert(() => view.ShouldServiceAsPrimary.ShouldBeFalse(), duration: Timeout);
|
|
}
|
|
|
|
// ---------------- helpers ----------------
|
|
|
|
private RedundancyRoleView SpawnAndTellRole(RedundancyRole role, int driverMemberCount)
|
|
{
|
|
var view = new RedundancyRoleView();
|
|
SpawnHost(view, driverMemberCount).Tell(Snapshot(TestNode, role));
|
|
return view;
|
|
}
|
|
|
|
private RedundancyRoleView SpawnAndTellForeignSnapshot(int driverMemberCount)
|
|
{
|
|
var view = new RedundancyRoleView();
|
|
SpawnHost(view, driverMemberCount).Tell(Snapshot(NodeId.Parse("some-other-node"), RedundancyRole.Primary));
|
|
return view;
|
|
}
|
|
|
|
private IActorRef SpawnHost(IRedundancyRoleView view, int driverMemberCount) =>
|
|
Sys.ActorOf(DriverHostActor.Props(
|
|
NewInMemoryDbFactory(),
|
|
TestNode,
|
|
coordinator: null,
|
|
driverMemberCountProvider: () => driverMemberCount,
|
|
redundancyRoleView: view));
|
|
|
|
private static RedundancyStateChanged Snapshot(NodeId node, RedundancyRole role) =>
|
|
new(
|
|
[
|
|
new NodeRedundancyState(node, role,
|
|
IsClusterLeader: role == RedundancyRole.Primary,
|
|
IsRoleLeaderForDriver: role == RedundancyRole.Primary,
|
|
AsOfUtc: DateTime.UtcNow),
|
|
],
|
|
CorrelationId.NewId());
|
|
}
|