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:
Joseph Doherty
2026-10-08 08:43:06 -04:00
parent e93d115cbe
commit 3be8a1217e
7 changed files with 203 additions and 11 deletions
@@ -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>