Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDegradedAckTests.cs
T
Joseph Doherty 90d7f6dfca fix(deploy): hold the Applied ack until the address-space apply outcome is known (#601)
DriverHostActor no longer acks Applied the moment the revision advances. It
asks OpcUaPublishActor for the outcome (RebuildAddressSpace.ReportOutcome) and
acks Applied+Degraded(reason) when the apply was degraded; a missing outcome
times out (60 s, inside the coordinator's 2 min deadline) to a degraded ack,
never a clean one. Re-dispatching a revision whose apply was degraded or is
still unconfirmed re-applies instead of taking the clean idempotent
short-circuit - that re-apply is also what repairs the degraded nodes.
Tests with a bare publish probe use a clean-outcome auto-pilot probe.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:29:45 -04:00

204 lines
11 KiB
C#

using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Gitea #601 (archreview 01/S-9) — the deploy ack must be honest about the address-space apply. It used
/// to be sent the moment the revision advanced, before <c>OpcUaPublishActor</c> had even dequeued the
/// rebuild, so a degraded apply (swallowed sink faults; <c>_lastApplied</c> deliberately not advanced)
/// still acked a clean <see cref="ApplyAckOutcome.Applied"/> and the deployment sealed green fleet-wide.
/// Now the Applied ack waits for the publish actor's <see cref="OpcUaPublishActor.RebuildCompleted"/> and
/// carries its <see cref="ApplyAck.Degraded"/> verdict; a missing verdict times out to degraded, never clean.
/// </summary>
[Collection("opcua-publish-meters")]
public sealed class DriverHostActorDegradedAckTests : RuntimeActorTestBase
{
private static readonly NodeId TestNode = NodeId.Parse("driver-test");
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
[Fact]
public void The_Applied_ack_waits_for_the_address_space_outcome_and_a_clean_one_acks_clean()
{
var (db, deploymentId) = SeedOneTagDeployment();
var coordinator = CreateTestProbe();
var publish = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref, localRoles: new HashSet<string> { "driver" },
opcUaPublishActor: publish.Ref));
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
var rebuild = publish.FishForMessage<OpcUaPublishActor.RebuildAddressSpace>(_ => true, PresenceBudget);
rebuild.ReportOutcome.ShouldBeTrue();
// The ack is HELD until the outcome lands — the pre-#601 code had already sent it by now.
coordinator.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
publish.Reply(new OpcUaPublishActor.RebuildCompleted(rebuild.Correlation, rebuild.DeploymentId, false, null));
var ack = coordinator.ExpectMsg<ApplyAck>(PresenceBudget);
ack.Outcome.ShouldBe(ApplyAckOutcome.Applied);
ack.Degraded.ShouldBeFalse();
ack.DegradedReason.ShouldBeNull();
}
[Fact]
public void A_degraded_address_space_outcome_acks_Applied_but_degraded_with_the_reason()
{
var (db, deploymentId) = SeedOneTagDeployment();
var coordinator = CreateTestProbe();
var publish = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref, localRoles: new HashSet<string> { "driver" },
opcUaPublishActor: publish.Ref));
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
var rebuild = publish.FishForMessage<OpcUaPublishActor.RebuildAddressSpace>(_ => true, PresenceBudget);
publish.Reply(new OpcUaPublishActor.RebuildCompleted(
rebuild.Correlation, rebuild.DeploymentId, Degraded: true, DegradedReason: "failedNodes=2"));
var ack = coordinator.ExpectMsg<ApplyAck>(PresenceBudget);
ack.Outcome.ShouldBe(ApplyAckOutcome.Applied);
ack.Degraded.ShouldBeTrue();
ack.DegradedReason.ShouldBe("failedNodes=2");
ack.DeploymentId.ShouldBe(deploymentId);
}
[Fact]
public void A_real_publish_actor_whose_sink_faults_produces_a_degraded_ack_end_to_end()
{
// No hand-written verdict: the real OpcUaPublishActor + AddressSpaceApplier over a sink that faults on
// every node. This is the production shape of the defect — a degraded apply that sealed green.
var (db, deploymentId) = SeedOneTagDeployment();
var coordinator = CreateTestProbe();
var sink = new FaultingSink();
var publish = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
sink: sink, applier: new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance)));
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref, localRoles: new HashSet<string> { "driver" },
opcUaPublishActor: publish));
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
var ack = coordinator.ExpectMsg<ApplyAck>(PresenceBudget);
ack.Outcome.ShouldBe(ApplyAckOutcome.Applied);
ack.Degraded.ShouldBeTrue();
ack.DegradedReason.ShouldNotBeNull();
ack.DegradedReason.ShouldContain("failedNodes=");
}
[Fact]
public void A_real_publish_actor_with_a_healthy_sink_produces_a_clean_ack_end_to_end()
{
// The control for the test above: same wiring, a sink that does not fault ⇒ clean.
var (db, deploymentId) = SeedOneTagDeployment();
var coordinator = CreateTestProbe();
var sink = new FaultingSink(fault: false);
var publish = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
sink: sink, applier: new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance)));
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref, localRoles: new HashSet<string> { "driver" },
opcUaPublishActor: publish));
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
var ack = coordinator.ExpectMsg<ApplyAck>(PresenceBudget);
ack.Outcome.ShouldBe(ApplyAckOutcome.Applied);
ack.Degraded.ShouldBeFalse();
}
[Fact]
public void No_outcome_within_the_timeout_acks_degraded_never_clean()
{
var (db, deploymentId) = SeedOneTagDeployment();
var coordinator = CreateTestProbe();
var publish = CreateTestProbe(); // never replies — a hung / lost apply
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref, localRoles: new HashSet<string> { "driver" },
opcUaPublishActor: publish.Ref, applyOutcomeTimeout: TimeSpan.FromMilliseconds(300)));
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
var ack = coordinator.ExpectMsg<ApplyAck>(PresenceBudget);
ack.Outcome.ShouldBe(ApplyAckOutcome.Applied);
ack.Degraded.ShouldBeTrue();
ack.DegradedReason.ShouldNotBeNull();
ack.DegradedReason.ShouldContain("did not report an outcome");
// A late outcome after the timeout must not produce a second ack.
var rebuild = publish.FishForMessage<OpcUaPublishActor.RebuildAddressSpace>(_ => true, PresenceBudget);
publish.Reply(new OpcUaPublishActor.RebuildCompleted(rebuild.Correlation, rebuild.DeploymentId, false, null));
coordinator.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
[Fact]
public void Redispatching_a_degraded_revision_reapplies_instead_of_acking_clean()
{
var (db, deploymentId) = SeedOneTagDeployment();
var coordinator = CreateTestProbe();
var publish = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref, localRoles: new HashSet<string> { "driver" },
opcUaPublishActor: publish.Ref));
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
var first = publish.FishForMessage<OpcUaPublishActor.RebuildAddressSpace>(_ => true, PresenceBudget);
publish.Reply(new OpcUaPublishActor.RebuildCompleted(first.Correlation, first.DeploymentId, true, "failedNodes=1"));
coordinator.ExpectMsg<ApplyAck>(PresenceBudget).Degraded.ShouldBeTrue();
// Same revision again: the old idempotent short-circuit would ack a CLEAN Applied right here. It must
// re-apply instead (a second rebuild request), and ack whatever THAT apply reports.
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
var second = publish.FishForMessage<OpcUaPublishActor.RebuildAddressSpace>(_ => true, PresenceBudget);
coordinator.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
publish.Reply(new OpcUaPublishActor.RebuildCompleted(second.Correlation, second.DeploymentId, false, null));
coordinator.ExpectMsg<ApplyAck>(PresenceBudget).Degraded.ShouldBeFalse();
// Now clean: a third dispatch of the same revision takes the short-circuit again (no rebuild).
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
var third = coordinator.ExpectMsg<ApplyAck>(PresenceBudget);
third.Degraded.ShouldBeFalse();
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
private (IDbContextFactory<OtOpcUaConfigDbContext> Db, DeploymentId Id) SeedOneTagDeployment()
{
var db = NewInMemoryDbFactory();
var id = V3ArtifactSeeder.SeedV3Deployment(db, RevA,
rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) });
return (db, id);
}
/// <summary>A sink that throws from every node-creating call when <c>fault</c> is set.</summary>
private sealed class FaultingSink(bool fault = true) : IOpcUaAddressSpaceSink
{
private void MaybeFault()
{
if (fault) throw new InvalidOperationException("simulated sink fault");
}
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) => MaybeFault();
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => MaybeFault();
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) => MaybeFault();
public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
}