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; /// /// Gitea #537 — the bridge that carries the continuous-historization capture verdict out of /// the actor and into , where the recorder reads it per value. /// /// /// /// Both nodes of a redundant pair serve the same address space and see the same dependency-mux /// fan-out, so an ungated recorder writes every sample twice. Measured on the docker-dev central /// pair against a real gateway: the write counter advanced twice per value, and halved when one /// node was stopped. /// /// /// These cases share the role rule with but differ on /// which peer counts. The alarm queue replicates, so the relevant peer is whoever holds a copy /// of this node's rows; historization is fleet-wide by historian tag name, so the relevant peer /// is whoever records this node's refs — a partner in its own per-cluster mesh. /// /// public sealed class DriverHostActorHistorizationRoleViewTests : RuntimeActorTestBase { private static readonly NodeId TestNode = NodeId.Parse("driver-histview-test"); private static readonly NodeId MeshPeer = NodeId.Parse("mesh-peer:4053"); private static readonly NodeId ForeignNode = NodeId.Parse("other-cluster:4053"); private static readonly TimeSpan Timeout = PresenceBudget; /// /// Before any snapshot arrives the view must read open, not closed. /// /// /// A deployment that runs no redundancy never publishes here at all. Defaulting closed would /// leave it with no trend data whatsoever and nothing to say why. /// [Fact] public void An_unpublished_view_captures() => new RedundancyRoleView().ShouldRecordHistorizedValues.ShouldBeTrue(); /// A Secondary stands down for a Primary in its own mesh — the duplicate-write case. [Fact] public void Secondary_stands_down_for_a_primary_in_its_own_mesh() { var view = new RecordingHistorizationView(); SpawnHost(view, sharesScopeWith: MeshPeer).Tell(Snapshot( (TestNode, RedundancyRole.Secondary), (MeshPeer, RedundancyRole.Primary))); AwaitAssert(() => view.Published.ShouldBe([false]), duration: Timeout); } /// /// A Secondary whose Primary is in ANOTHER cluster keeps capturing. /// /// /// That node runs a different configuration and records none of these tags, so standing down for /// it would end this cluster's historization silently — the drain would simply report success on /// an empty outbox forever. Same scope mismatch that once suspended the alarm drain fleet-wide. /// [Fact] public void Secondary_keeps_capturing_when_the_primary_records_other_tags() { var view = new RecordingHistorizationView(); SpawnHost(view, sharesScopeWith: MeshPeer).Tell(Snapshot( (TestNode, RedundancyRole.Secondary), (ForeignNode, RedundancyRole.Primary))); AwaitAssert(() => view.Published.ShouldBe([true]), duration: Timeout); } /// /// With NO way to establish which peers share this node's address space, it never stands down. /// /// /// The predicate is null on a node carrying no cluster-scoped role — the legacy, pre-Phase-6 /// shape, where the redundancy election is fleet-wide and the elected Primary usually belongs to /// some other cluster. Duplicates are recoverable; a silent gap is not, so unproven means /// capture. Delete the null check in the wiring and this is the case that goes red. /// [Fact] public void An_unprovable_scope_never_stands_down() { var view = new RecordingHistorizationView(); SpawnHost(view, sharesScopeWith: null).Tell(Snapshot( (TestNode, RedundancyRole.Secondary), (MeshPeer, RedundancyRole.Primary))); AwaitAssert(() => view.Published.ShouldBe([true]), duration: Timeout); } /// Primary captures. [Fact] public void Primary_captures() { var view = new RecordingHistorizationView(); SpawnHost(view, sharesScopeWith: MeshPeer).Tell(Snapshot( (TestNode, RedundancyRole.Primary), (MeshPeer, RedundancyRole.Secondary))); // Published, not current — `true` is also the seed, so asserting the property would pass before // the actor had processed anything. AwaitAssert(() => view.Published.ShouldBe([true]), duration: Timeout); } /// /// Unknown role captures — the snapshot never names this node, the documented identity-mismatch /// shape. /// [Fact] public void Unknown_role_captures() { var view = new RecordingHistorizationView(); SpawnHost(view, sharesScopeWith: MeshPeer).Tell(Snapshot( (MeshPeer, RedundancyRole.Primary), (ForeignNode, RedundancyRole.Secondary))); AwaitAssert(() => view.Published.ShouldBe([true]), duration: Timeout); } /// A promotion must MOVE the view, not merely leave it at its open seed. [Fact] public void A_promotion_reopens_a_closed_view() { var view = new RecordingHistorizationView(); IActorRef host = SpawnHost(view, sharesScopeWith: MeshPeer); host.Tell(Snapshot((TestNode, RedundancyRole.Secondary), (MeshPeer, RedundancyRole.Primary))); AwaitAssert(() => view.Published.ShouldBe([false]), duration: Timeout); host.Tell(Snapshot((TestNode, RedundancyRole.Primary), (MeshPeer, RedundancyRole.Secondary))); AwaitAssert(() => view.Published.ShouldBe([false, true]), duration: Timeout); } // ---------------- helpers ---------------- /// /// A view that remembers every published decision, so a test asserts on the actor's ACTION /// rather than on a state its open seed already satisfies. /// private sealed class RecordingHistorizationView : IHistorizationRoleView { private readonly List _published = []; private readonly Lock _gate = new(); private readonly RedundancyRoleView _inner = new(); /// Every value published so far, in order. public IReadOnlyList Published { get { lock (_gate) return _published.ToArray(); } } public bool ShouldRecordHistorizedValues => _inner.ShouldRecordHistorizedValues; public void PublishHistorizationRole(bool shouldRecordHistorizedValues) { lock (_gate) _published.Add(shouldRecordHistorizedValues); _inner.PublishHistorizationRole(shouldRecordHistorizedValues); } } private IActorRef SpawnHost(IHistorizationRoleView view, NodeId? sharesScopeWith) => Sys.ActorOf(DriverHostActor.Props( NewInMemoryDbFactory(), TestNode, ackRouter: null, driverMemberCountProvider: () => 2, historizationRoleView: view, sharesHistorizationScopeWithLocal: sharesScopeWith is { } peer ? node => node == peer : null)); private static RedundancyStateChanged Snapshot(params (NodeId Node, RedundancyRole Role)[] nodes) => new( nodes.Select(n => new NodeRedundancyState( n.Node, n.Role, IsClusterLeader: n.Role == RedundancyRole.Primary, IsDriverPrimary: n.Role == RedundancyRole.Primary, AsOfUtc: DateTime.UtcNow)) .ToList(), CorrelationId.NewId()); }