fix(runtime): the passive half of a LocalDb pair stands its alarm drain down for its mesh partner (review R6 MINOR-4)
The drain gate's 'is my queue-sharing peer Primary?' used only the host of
LocalDb:Replication:PeerAddress, which the passive half (ApiKey +
SyncListenPort) does not set - so as Secondary it kept draining beside its
Primary partner and every alarm event reached the historian twice.
DriverHostActor takes a sharesAlarmQueueWithLocal predicate used when no
peer host is known; WithOtOpcUaRuntimeActors builds it
(BuildAlarmQueuePeerPredicate) for a SharesRowsWithPeer node with no
PeerAddress as 'a member of my own cluster-{ClusterId} mesh other than
me'. A node sharing no rows, or with no mesh role, gets none and keeps
draining. AlarmHistorian.md / Redundancy.md / pair-replication runbook
updated.
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -135,6 +135,43 @@ public sealed class DriverHostActorRoleViewTests : RuntimeActorTestBase
|
||||
AwaitAssert(() => view.Published.ShouldBe([true, false]), duration: Timeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review R6 MINOR-4 — the PASSIVE half of a pair dials nobody (no <c>PeerAddress</c>, so no peer host) but
|
||||
/// holds the same replicated rows, so as Secondary it must stand down for its Primary partner too, which it
|
||||
/// identifies through the queue-sharing predicate (its mesh partner). Before the fix it published
|
||||
/// <c>true</c> here and both halves drained. A Primary the predicate does NOT name is the control: the
|
||||
/// passive half keeps draining, so the deny comes from identifying the partner, not from being Secondary.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_passive_secondary_closes_the_view_for_its_mesh_partner_and_only_for_it()
|
||||
{
|
||||
var partner = NodeId.Parse("site-a-1:4053");
|
||||
foreach (var (primary, expected) in new[] { (partner, false), (NodeId.Parse("site-b-1:4053"), true) })
|
||||
{
|
||||
var view = new RecordingRoleView();
|
||||
var host = Sys.ActorOf(DriverHostActor.Props(
|
||||
NewInMemoryDbFactory(),
|
||||
TestNode,
|
||||
ackRouter: null,
|
||||
driverMemberCountProvider: () => 2,
|
||||
redundancyRoleView: view,
|
||||
replicationPeerHost: null,
|
||||
sharesAlarmQueueWithLocal: n => n == partner));
|
||||
|
||||
host.Tell(new RedundancyStateChanged(
|
||||
[
|
||||
new NodeRedundancyState(TestNode, RedundancyRole.Secondary,
|
||||
IsClusterLeader: false, IsDriverPrimary: false, AsOfUtc: DateTime.UtcNow),
|
||||
new NodeRedundancyState(primary, RedundancyRole.Primary,
|
||||
IsClusterLeader: true, IsDriverPrimary: true, AsOfUtc: DateTime.UtcNow),
|
||||
],
|
||||
CorrelationId.NewId()));
|
||||
|
||||
AwaitAssert(() => view.Published.ShouldBe([expected]), duration: Timeout);
|
||||
Sys.Stop(host);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- helpers ----------------
|
||||
|
||||
/// <summary>
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian;
|
||||
|
||||
/// <summary>
|
||||
/// Review R6 MINOR-4 — how the PASSIVE half of a LocalDb pair (<c>ApiKey</c> + <c>SyncListenPort</c>, no
|
||||
/// <c>PeerAddress</c>) learns which node holds its replicated alarm queue: its per-cluster mesh partner.
|
||||
/// Before this it had no peer at all, so as Secondary it kept draining beside its Primary partner and the
|
||||
/// historian received every alarm event twice.
|
||||
/// </summary>
|
||||
public sealed class AlarmQueuePeerPredicateTests
|
||||
{
|
||||
private static readonly NodeId Self = NodeId.Parse("site-a-2:4053");
|
||||
private static readonly NodeId Partner = NodeId.Parse("site-a-1:4053");
|
||||
private static readonly NodeId OtherPair = NodeId.Parse("site-b-1:4053");
|
||||
|
||||
/// <summary>The passive half (key + listener) with a mesh role: its mesh partner is its queue peer, itself and
|
||||
/// a node from another mesh are not.</summary>
|
||||
[Fact]
|
||||
public void The_passive_half_identifies_its_mesh_partner()
|
||||
{
|
||||
var predicate = ServiceCollectionExtensions.BuildAlarmQueuePeerPredicate(
|
||||
Config(apiKey: "k", syncListenPort: "4057"), new MeshRoleInfo("cluster-SITE-A", Self, Partner), replicationPeerHost: null);
|
||||
|
||||
predicate.ShouldNotBeNull();
|
||||
predicate(Partner).ShouldBeTrue();
|
||||
predicate(Self).ShouldBeFalse();
|
||||
predicate(OtherPair).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>The dialling half is matched by its PeerAddress host instead — no predicate.</summary>
|
||||
[Fact]
|
||||
public void The_dialling_half_needs_no_predicate() =>
|
||||
ServiceCollectionExtensions.BuildAlarmQueuePeerPredicate(
|
||||
Config(apiKey: "k", peerAddress: "http://site-a-2:4057"), new MeshRoleInfo("cluster-SITE-A", Self, Partner),
|
||||
replicationPeerHost: "site-a-2").ShouldBeNull();
|
||||
|
||||
/// <summary>A node sharing its queue with nobody must never stand down, whatever its mesh says.</summary>
|
||||
[Theory]
|
||||
[InlineData(null, "4057")] // listener without the key — fail-closed, shares nothing
|
||||
[InlineData("k", null)] // key without a path — shares nothing
|
||||
[InlineData(null, null)] // unreplicated
|
||||
public void A_node_that_shares_no_rows_gets_no_predicate(string? apiKey, string? syncListenPort) =>
|
||||
ServiceCollectionExtensions.BuildAlarmQueuePeerPredicate(
|
||||
Config(apiKey: apiKey, syncListenPort: syncListenPort), new MeshRoleInfo("cluster-SITE-A", Self, Partner),
|
||||
replicationPeerHost: null).ShouldBeNull();
|
||||
|
||||
/// <summary>A legacy node with no cluster-scoped role cannot prove which node is its partner; standing down for
|
||||
/// the wrong Primary would strand its rows, so it gets none (and keeps draining).</summary>
|
||||
[Fact]
|
||||
public void A_node_without_a_mesh_role_gets_no_predicate() =>
|
||||
ServiceCollectionExtensions.BuildAlarmQueuePeerPredicate(
|
||||
Config(apiKey: "k", syncListenPort: "4057"), new MeshRoleInfo(null, Self, Partner), replicationPeerHost: null)
|
||||
.ShouldBeNull();
|
||||
|
||||
private static IConfiguration Config(string? apiKey = null, string? peerAddress = null, string? syncListenPort = null) =>
|
||||
new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["LocalDb:Replication:ApiKey"] = apiKey,
|
||||
["LocalDb:Replication:PeerAddress"] = peerAddress,
|
||||
["LocalDb:SyncListenPort"] = syncListenPort,
|
||||
}).Build();
|
||||
|
||||
private sealed class MeshRoleInfo(string? clusterRole, NodeId self, NodeId partner) : IClusterRoleInfo
|
||||
{
|
||||
public NodeId LocalNode => self;
|
||||
public IReadOnlySet<string> LocalRoles { get; } = new HashSet<string>(["driver"]);
|
||||
public string? ClusterRole => clusterRole;
|
||||
public string? ClusterId => clusterRole?["cluster-".Length..];
|
||||
public bool HasRole(string role) => LocalRoles.Contains(role) || role == clusterRole;
|
||||
public IReadOnlyList<NodeId> MembersWithRole(string role) => role == clusterRole ? [self, partner] : [];
|
||||
public NodeId? RoleLeader(string role) => null;
|
||||
public event EventHandler<RoleLeaderChangedEventArgs>? RoleLeaderChanged
|
||||
{
|
||||
add { _ = value; }
|
||||
remove { _ = value; }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user