fix(runtime): pin the Primary-verdict member-event subscription with a test; document the pre-join allow window (review R6 MINOR-2/3)

MINOR-3: new DriverHostActorPrimaryRoleSourceTests case drives the
deny flip with a real ClusterEvent.MemberUp on the EventStream (how the
cluster's domain-event publisher reaches a Cluster.Subscribe subscriber)
instead of a direct RepublishPrimaryVerdict tell; it fails when the
Cluster.Subscribe(IMemberEvent) call is deleted.

MINOR-2: the published verdict MIRRORS the live gate; before this node has
joined its member count is 0 and both allow (seconds; up to the bootstrap
guard's 25 s probe), so the Sparkplug primary-host claim can be held on
both nodes in that window (native ack is unaffected - view and route
agree). Behaviour deliberately unchanged: denying until self is Up would
change the live gate too and delay writes/acks on a single-node cluster's
own join. Comments + AlarmTracking.md / Redundancy.md / Mqtt.md corrected.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 08:33:52 -04:00
parent 52708bff52
commit e93d115cbe
5 changed files with 52 additions and 4 deletions
@@ -66,6 +66,31 @@ public sealed class DriverHostActorPrimaryRoleSourceTests : RuntimeActorTestBase
AwaitAssert(() => source.Published.ShouldBe([true, false]), duration: Timeout);
}
/// <summary>
/// Review R6 MINOR-3 — the same flip driven by a real cluster MEMBER EVENT rather than a direct
/// <see cref="DriverHostActor.RepublishPrimaryVerdict"/> tell, so it fails if the host's
/// <c>Cluster.Subscribe(… IMemberEvent)</c> or its <c>IMemberEvent</c> handler is deleted. The event is published
/// on the ActorSystem EventStream, which is exactly how the cluster's domain-event publisher delivers it to a
/// <c>Cluster.Subscribe</c> subscriber — and to nothing else. Re-published inside the wait because the
/// subscription is registered asynchronously by the publisher actor after <c>PreStart</c>.
/// </summary>
[Fact]
public void A_cluster_member_event_flips_the_published_verdict_without_a_direct_republish()
{
var source = new RecordingPrimaryRoleSource();
var members = 1;
SpawnHost(source, () => Volatile.Read(ref members));
AwaitAssert(() => source.Published.ShouldBe([true]), duration: Timeout);
Volatile.Write(ref members, 2);
var memberUp = new Akka.Cluster.ClusterEvent.MemberUp(Akka.Cluster.Cluster.Get(Sys).SelfMember);
AwaitAssert(() =>
{
Sys.EventStream.Publish(memberUp);
source.Published.ShouldBe([true, false]);
}, duration: Timeout, interval: TimeSpan.FromMilliseconds(100));
}
private IActorRef SpawnHost(IDriverPrimaryRoleSource source, Func<int> driverMemberCount) =>
Sys.ActorOf(DriverHostActor.Props(
NewInMemoryDbFactory(),