e3dcb180c3
Review R1: - M1 (#609): DriverHostActor.StopChild releases the instance's resilience state before evicting the hub. - m2 (#597): DriverHostActor publishes ShouldServiceAsPrimary() to IDriverPrimaryRoleSource at start and on cluster membership changes (deduped), so the native-ack gate denies in a pair's boot window. - m4 (#601): ApplyCachedArtifact returns false on empty/malformed bytes; the cached revision is not adopted, Direct mode goes Stale (no RUNNING FROM CACHE), FetchAndCache lands Steady-with-no-revision and a re-dispatch of the same revision really applies. - m3 (#603): LocalDbReplicationPolicy splits into CapturesChanges (PeerAddress||ApiKey, registration) and SharesRowsWithPeer (ApiKey && (PeerAddress||SyncListenPort>0), alarm drain gate). - m5 (#618): Program.cs catch-all filters HostAbortedException (design-time tooling). - m6 (Cluster-002): ClusterRoleInfo seeds only Up/WeaklyUp members. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
110 lines
4.7 KiB
C#
110 lines
4.7 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.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
|
|
|
/// <summary>
|
|
/// Review R1 m2 (Gitea #597) — the Primary data-plane verdict published to <see cref="IDriverPrimaryRoleSource"/>
|
|
/// (read by the native-ack gate and the Sparkplug primary-host claim) mirrors the host's own live gate from the
|
|
/// moment the host starts, not only from the first redundancy snapshot.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <c>RedundancyRoleView</c> is seeded with the single-node verdict (allow). Before this fix nothing published to
|
|
/// it until a snapshot arrived, so on a pair both nodes accepted a native ack through the whole boot window while
|
|
/// <c>DriverHostActor</c> — which evaluates the driver member count live — denied the upstream route on the
|
|
/// non-Primary. Assertions are on what was PUBLISHED: the seed already reads <c>true</c>, so a current-value
|
|
/// assertion of the single-node case would pass with no publish at all.
|
|
/// </remarks>
|
|
public sealed class DriverHostActorPrimaryRoleSourceTests : RuntimeActorTestBase
|
|
{
|
|
private static readonly NodeId TestNode = NodeId.Parse("driver-primary-source-test");
|
|
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
|
|
|
|
/// <summary>On a pair, an unknown role publishes DENY at start — before any snapshot. The later Primary snapshot
|
|
/// is the positive control: the source is wired, and the deny was the verdict, not a missing publish.</summary>
|
|
[Fact]
|
|
public void A_paired_host_publishes_deny_before_the_first_snapshot()
|
|
{
|
|
var source = new RecordingPrimaryRoleSource();
|
|
var host = SpawnHost(source, () => 2);
|
|
|
|
AwaitAssert(() => source.Published.ShouldBe([false]), duration: Timeout);
|
|
|
|
host.Tell(Snapshot(RedundancyRole.Primary));
|
|
AwaitAssert(() => source.Published.ShouldBe([false, true]), duration: Timeout);
|
|
}
|
|
|
|
/// <summary>A single-driver node keeps the allow posture — it is published, not merely seeded.</summary>
|
|
[Fact]
|
|
public void A_single_node_host_publishes_allow_at_start()
|
|
{
|
|
var source = new RecordingPrimaryRoleSource();
|
|
SpawnHost(source, () => 1);
|
|
|
|
AwaitAssert(() => source.Published.ShouldBe([true]), duration: Timeout);
|
|
}
|
|
|
|
/// <summary>A driver peer coming Up while the role is still unknown flips the published verdict to deny, with no
|
|
/// snapshot involved — the membership re-publish path.</summary>
|
|
[Fact]
|
|
public void A_peer_joining_before_any_snapshot_flips_the_published_verdict_to_deny()
|
|
{
|
|
var source = new RecordingPrimaryRoleSource();
|
|
var members = 1;
|
|
var host = SpawnHost(source, () => Volatile.Read(ref members));
|
|
AwaitAssert(() => source.Published.ShouldBe([true]), duration: Timeout);
|
|
|
|
Volatile.Write(ref members, 2);
|
|
host.Tell(DriverHostActor.RepublishPrimaryVerdict.Instance);
|
|
|
|
AwaitAssert(() => source.Published.ShouldBe([true, false]), duration: Timeout);
|
|
}
|
|
|
|
private IActorRef SpawnHost(IDriverPrimaryRoleSource source, Func<int> driverMemberCount) =>
|
|
Sys.ActorOf(DriverHostActor.Props(
|
|
NewInMemoryDbFactory(),
|
|
TestNode,
|
|
ackRouter: null,
|
|
driverMemberCountProvider: driverMemberCount,
|
|
primaryRoleSource: source));
|
|
|
|
private static RedundancyStateChanged Snapshot(RedundancyRole role) =>
|
|
new(
|
|
[
|
|
new NodeRedundancyState(TestNode, role,
|
|
IsClusterLeader: role == RedundancyRole.Primary,
|
|
IsDriverPrimary: role == RedundancyRole.Primary,
|
|
AsOfUtc: DateTime.UtcNow),
|
|
],
|
|
CorrelationId.NewId());
|
|
|
|
/// <summary>Remembers every published verdict, so tests assert on the actor's action, not on the seed.</summary>
|
|
private sealed class RecordingPrimaryRoleSource : IDriverPrimaryRoleSource
|
|
{
|
|
private readonly List<bool> _published = [];
|
|
private readonly Lock _gate = new();
|
|
|
|
/// <summary>Every value published so far, in order.</summary>
|
|
public IReadOnlyList<bool> Published
|
|
{
|
|
get { lock (_gate) return _published.ToArray(); }
|
|
}
|
|
|
|
public bool ShouldActAsPrimary
|
|
{
|
|
get { lock (_gate) return _published.Count == 0 || _published[^1]; }
|
|
}
|
|
|
|
public void PublishPrimaryRole(bool shouldActAsPrimary)
|
|
{
|
|
lock (_gate) _published.Add(shouldActAsPrimary);
|
|
}
|
|
}
|
|
}
|