Merge b640-601: honest deploy acks — Applied only after the address-space outcome, degraded flag + reason (#601)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe # Conflicts: # src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs
This commit is contained in:
+220
@@ -0,0 +1,220 @@
|
||||
using System.Text;
|
||||
using Akka.Actor;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.Coordinators;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #601 — central's side of the honest deploy ack. A node that applied a deployment but whose
|
||||
/// address-space apply was DEGRADED acks <see cref="ApplyAckOutcome.Applied"/> with
|
||||
/// <see cref="ApplyAck.Degraded"/>. The deployment still SEALS (the revision is what the node now runs, and
|
||||
/// "latest sealed" drives drift + the publish actor's fallback) but never looks clean: the node's
|
||||
/// <c>NodeDeploymentState</c> row keeps status Applied with its degraded reason in <c>FailureReason</c>,
|
||||
/// and the <c>Deployment</c> row's <c>FailureReason</c> names the degraded nodes. An ack from an old node
|
||||
/// (no degraded field) is handled exactly as before.
|
||||
/// </summary>
|
||||
public sealed class ConfigPublishCoordinatorDegradedAckTests : ControlPlaneActorTestBase
|
||||
{
|
||||
private static readonly RevisionHash TestRevision = RevisionHash.Parse(new string('c', 64));
|
||||
private static readonly TimeSpan NoTimeout = TimeSpan.FromMinutes(5);
|
||||
private const string NodeA = "site-a-1:4053";
|
||||
private const string NodeB = "site-a-2:4053";
|
||||
|
||||
[Fact]
|
||||
public void A_degraded_ack_seals_but_is_recorded_as_degraded_on_the_node_and_the_deployment()
|
||||
{
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedDispatchingDeployment(dbFactory);
|
||||
SeedNodes(dbFactory, NodeA, NodeB);
|
||||
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
|
||||
actor.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId()));
|
||||
AwaitStatus(dbFactory, DeploymentStatus.AwaitingApplyAcks);
|
||||
|
||||
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse(NodeA), ApplyAckOutcome.Applied, null,
|
||||
CorrelationId.NewId()));
|
||||
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse(NodeB), ApplyAckOutcome.Applied, null,
|
||||
CorrelationId.NewId(), Degraded: true, DegradedReason: "address-space apply degraded (failedNodes=3)"));
|
||||
|
||||
AwaitStatus(dbFactory, DeploymentStatus.Sealed);
|
||||
using var db = dbFactory.CreateDbContext();
|
||||
|
||||
var deployment = db.Deployments.Single();
|
||||
deployment.FailureReason.ShouldNotBeNull();
|
||||
deployment.FailureReason.ShouldContain("degraded");
|
||||
deployment.FailureReason.ShouldContain(NodeB);
|
||||
deployment.FailureReason.ShouldNotContain(NodeA);
|
||||
|
||||
var rowB = db.NodeDeploymentStates.Single(s => s.NodeId == NodeB);
|
||||
rowB.Status.ShouldBe(NodeDeploymentStatus.Applied);
|
||||
rowB.FailureReason.ShouldNotBeNull();
|
||||
rowB.FailureReason.ShouldStartWith(ConfigPublishCoordinator.DegradedReasonPrefix);
|
||||
rowB.FailureReason.ShouldContain("failedNodes=3");
|
||||
ConfigPublishCoordinator.IsDegraded(rowB.Status, rowB.FailureReason).ShouldBeTrue();
|
||||
|
||||
var rowA = db.NodeDeploymentStates.Single(s => s.NodeId == NodeA);
|
||||
rowA.Status.ShouldBe(NodeDeploymentStatus.Applied);
|
||||
rowA.FailureReason.ShouldBeNull();
|
||||
ConfigPublishCoordinator.IsDegraded(rowA.Status, rowA.FailureReason).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Old_node_acks_without_the_degraded_field_seal_clean_exactly_as_before()
|
||||
{
|
||||
// The five-argument constructor IS the pre-#601 ack shape: Degraded defaults to false.
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedDispatchingDeployment(dbFactory);
|
||||
SeedNodes(dbFactory, NodeA, NodeB);
|
||||
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
|
||||
actor.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId()));
|
||||
AwaitStatus(dbFactory, DeploymentStatus.AwaitingApplyAcks);
|
||||
|
||||
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse(NodeA), ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
|
||||
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse(NodeB), ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
|
||||
|
||||
AwaitStatus(dbFactory, DeploymentStatus.Sealed);
|
||||
using var db = dbFactory.CreateDbContext();
|
||||
db.Deployments.Single().FailureReason.ShouldBeNull();
|
||||
db.NodeDeploymentStates.All(s => s.Status == NodeDeploymentStatus.Applied && s.FailureReason == null)
|
||||
.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_degraded_ack_alongside_a_failed_one_is_still_PartiallyFailed()
|
||||
{
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedDispatchingDeployment(dbFactory);
|
||||
SeedNodes(dbFactory, NodeA, NodeB);
|
||||
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
|
||||
actor.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId()));
|
||||
AwaitStatus(dbFactory, DeploymentStatus.AwaitingApplyAcks);
|
||||
|
||||
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse(NodeA), ApplyAckOutcome.Failed, "boom", CorrelationId.NewId()));
|
||||
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse(NodeB), ApplyAckOutcome.Applied, null,
|
||||
CorrelationId.NewId(), Degraded: true, DegradedReason: "x"));
|
||||
|
||||
AwaitStatus(dbFactory, DeploymentStatus.PartiallyFailed);
|
||||
using var db = dbFactory.CreateDbContext();
|
||||
db.NodeDeploymentStates.Single(s => s.NodeId == NodeA).FailureReason.ShouldBe("boom");
|
||||
db.NodeDeploymentStates.Single(s => s.NodeId == NodeA).Status.ShouldBe(NodeDeploymentStatus.Failed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_recovered_coordinator_remembers_a_degraded_ack_that_landed_before_failover()
|
||||
{
|
||||
// Node B's degraded ack was persisted by a previous singleton instance; this instance recovers the
|
||||
// in-flight deployment from the DB and must still seal it as degraded when node A acks clean.
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedDispatchingDeployment(dbFactory, DeploymentStatus.AwaitingApplyAcks);
|
||||
SeedNodes(dbFactory, NodeA, NodeB);
|
||||
using (var db = dbFactory.CreateDbContext())
|
||||
{
|
||||
db.NodeDeploymentStates.Add(new NodeDeploymentState
|
||||
{
|
||||
NodeId = NodeA, DeploymentId = deploymentId.Value, Status = NodeDeploymentStatus.Applying,
|
||||
});
|
||||
db.NodeDeploymentStates.Add(new NodeDeploymentState
|
||||
{
|
||||
NodeId = NodeB, DeploymentId = deploymentId.Value, Status = NodeDeploymentStatus.Applied,
|
||||
FailureReason = ConfigPublishCoordinator.DegradedReasonPrefix + "failedNodes=1",
|
||||
AppliedAtUtc = DateTime.UtcNow,
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
|
||||
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse(NodeA), ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
|
||||
|
||||
AwaitStatus(dbFactory, DeploymentStatus.Sealed);
|
||||
using var verify = dbFactory.CreateDbContext();
|
||||
var reason = verify.Deployments.Single().FailureReason;
|
||||
reason.ShouldNotBeNull();
|
||||
reason.ShouldContain(NodeB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_old_nodes_DPS_ack_without_the_degraded_fields_deserializes_as_a_clean_ack()
|
||||
{
|
||||
// The DPS ack path rides Akka's default serializer for the record. Serialize a NEW ack, strip the two
|
||||
// #601 members from the payload (what an old node sends), and decode it with this build.
|
||||
var ack = new ApplyAck(DeploymentId.NewId(), NodeId.Parse(NodeA), ApplyAckOutcome.Applied, null,
|
||||
CorrelationId.NewId(), Degraded: true, DegradedReason: "will be stripped");
|
||||
var serializer = Sys.Serialization.FindSerializerFor(ack);
|
||||
var json = JObject.Parse(Encoding.UTF8.GetString(serializer.ToBinary(ack)));
|
||||
json.Remove("Degraded").ShouldBeTrue();
|
||||
json.Remove("DegradedReason").ShouldBeTrue();
|
||||
|
||||
var back = (ApplyAck)serializer.FromBinary(
|
||||
Encoding.UTF8.GetBytes(json.ToString(Newtonsoft.Json.Formatting.None)), typeof(ApplyAck));
|
||||
|
||||
back.Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
||||
back.Degraded.ShouldBeFalse();
|
||||
back.DegradedReason.ShouldBeNull();
|
||||
back.DeploymentId.ShouldBe(ack.DeploymentId);
|
||||
|
||||
// ...and the new fields survive the same serializer when present.
|
||||
var full = (ApplyAck)serializer.FromBinary(serializer.ToBinary(ack), typeof(ApplyAck));
|
||||
full.ShouldBe(ack);
|
||||
}
|
||||
|
||||
private void AwaitStatus(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, DeploymentStatus expected) =>
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
using var db = dbFactory.CreateDbContext();
|
||||
db.Deployments.Single().Status.ShouldBe(expected);
|
||||
}, duration: TimeSpan.FromSeconds(5));
|
||||
|
||||
private static void SeedNodes(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, params string[] nodes)
|
||||
{
|
||||
using var db = dbFactory.CreateDbContext();
|
||||
db.ServerClusters.Add(new ServerCluster
|
||||
{
|
||||
ClusterId = "SITE-A",
|
||||
Name = "Site A",
|
||||
Enterprise = "zb",
|
||||
Site = "site-a",
|
||||
NodeCount = 2,
|
||||
RedundancyMode = RedundancyMode.Warm,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
foreach (var nodeId in nodes)
|
||||
{
|
||||
db.ClusterNodes.Add(new ClusterNode
|
||||
{
|
||||
NodeId = nodeId,
|
||||
ClusterId = "SITE-A",
|
||||
Host = nodeId.Split(':')[0],
|
||||
ApplicationUri = $"urn:OtOpcUa:{nodeId}",
|
||||
Enabled = true,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
}
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
private static DeploymentId SeedDispatchingDeployment(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
DeploymentStatus status = DeploymentStatus.Dispatching)
|
||||
{
|
||||
var id = DeploymentId.NewId();
|
||||
using var db = dbFactory.CreateDbContext();
|
||||
db.Deployments.Add(new Deployment
|
||||
{
|
||||
DeploymentId = id.Value,
|
||||
RevisionHash = TestRevision.Value,
|
||||
Status = status,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
db.SaveChanges();
|
||||
return id;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -67,7 +67,7 @@ public sealed class DriverHostActorBootFromCacheTests : RuntimeActorTestBase
|
||||
var blob = EmptyArtifact();
|
||||
var cached = new CachedDeploymentArtifact(
|
||||
DeploymentId.NewId().ToString(), CachedRev.Value, blob, DateTimeOffset.UtcNow.AddHours(-1));
|
||||
var publishProbe = CreateTestProbe();
|
||||
var publishProbe = this.CreateCleanOutcomePublishProbe();
|
||||
|
||||
Sys.ActorOf(DriverHostActor.Props(
|
||||
new ThrowingDbFactory(), TestNode, ackRouter: null,
|
||||
@@ -92,7 +92,7 @@ public sealed class DriverHostActorBootFromCacheTests : RuntimeActorTestBase
|
||||
var cached = new CachedDeploymentArtifact(
|
||||
DeploymentId.NewId().ToString(), CachedRev.Value,
|
||||
System.Text.Encoding.Latin1.GetBytes(text), DateTimeOffset.UtcNow.AddHours(-1));
|
||||
var publishProbe = CreateTestProbe();
|
||||
var publishProbe = this.CreateCleanOutcomePublishProbe();
|
||||
|
||||
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||||
new ThrowingDbFactory(), TestNode, ackRouter: null,
|
||||
|
||||
@@ -112,7 +112,7 @@ public sealed class DriverHostActorDbLessTests : RuntimeActorTestBase
|
||||
var cache = new RecordingArtifactCache();
|
||||
var fetcher = new RecordingFetcher(ArtifactBytes());
|
||||
var deploymentId = DeploymentId.NewId();
|
||||
var publish = CreateTestProbe();
|
||||
var publish = this.CreateCleanOutcomePublishProbe();
|
||||
var coordinator = CreateTestProbe();
|
||||
|
||||
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||||
@@ -141,7 +141,7 @@ public sealed class DriverHostActorDbLessTests : RuntimeActorTestBase
|
||||
[Fact]
|
||||
public void DbLessNode_WithLocalDbStore_SpawnsScriptedAlarmHost()
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var publish = this.CreateCleanOutcomePublishProbe();
|
||||
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||||
dbFactory: null,
|
||||
TestNode,
|
||||
@@ -162,7 +162,7 @@ public sealed class DriverHostActorDbLessTests : RuntimeActorTestBase
|
||||
[Fact]
|
||||
public void DbLessNode_WithNoStoreAndNoDbFactory_SkipsScriptedAlarmHost()
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var publish = this.CreateCleanOutcomePublishProbe();
|
||||
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||||
dbFactory: null,
|
||||
TestNode,
|
||||
@@ -185,7 +185,7 @@ public sealed class DriverHostActorDbLessTests : RuntimeActorTestBase
|
||||
[Fact]
|
||||
public void DbBackedNode_WithNoStore_SkipsScriptedAlarmHost()
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var publish = this.CreateCleanOutcomePublishProbe();
|
||||
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||||
dbFactory: NewInMemoryDbFactory(),
|
||||
TestNode,
|
||||
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
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();
|
||||
// Distinct from a real sink failure: it says the outcome was not reported in time.
|
||||
ack.DegradedReason.ShouldStartWith(DriverHostActor.OutcomeNotReportedReasonPrefix);
|
||||
ack.DegradedReason.ShouldNotContain("failedNodes=");
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_degraded_apply_records_its_reason_on_the_local_NodeDeploymentState_row()
|
||||
{
|
||||
// #601 review — a node WITH a ConfigDb writes its own row Applied/null before the outcome is known.
|
||||
// Central later adds the reason from the ack, but only when it accepts that ack (a stale/timed-out
|
||||
// deployment's ack is discarded), so the node must not leave its own row reading 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);
|
||||
publish.Reply(new OpcUaPublishActor.RebuildCompleted(rebuild.Correlation, rebuild.DeploymentId, true, "failedNodes=3"));
|
||||
coordinator.ExpectMsg<ApplyAck>(PresenceBudget).Degraded.ShouldBeTrue();
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
using var ctx = db.CreateDbContext();
|
||||
var row = ctx.NodeDeploymentStates.Single(r => r.NodeId == TestNode.Value && r.DeploymentId == deploymentId.Value);
|
||||
row.Status.ShouldBe(ZB.MOM.WW.OtOpcUa.Configuration.Enums.NodeDeploymentStatus.Applied);
|
||||
row.FailureReason.ShouldBe(ApplyAck.DegradedReasonPrefix + "failedNodes=3");
|
||||
}, PresenceBudget);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_default_outcome_timeout_is_derived_from_centrals_apply_deadline_minus_a_margin()
|
||||
{
|
||||
// A slow-but-clean rebuild must not be labelled degraded at 60 s, yet the held ack must still
|
||||
// reach central before its apply deadline. Both sides read ONE shared constant.
|
||||
DeployTimings.ApplyDeadline.ShouldBe(TimeSpan.FromMinutes(2));
|
||||
DriverHostActor.DefaultApplyOutcomeTimeout.ShouldBe(DeployTimings.ApplyDeadline - DeployTimings.ApplyOutcomeSafetyMargin);
|
||||
DeployTimings.ApplyOutcomeSafetyMargin.ShouldBe(TimeSpan.FromSeconds(15));
|
||||
DriverHostActor.DefaultApplyOutcomeTimeout.ShouldBeGreaterThan(TimeSpan.FromSeconds(60));
|
||||
DriverHostActor.DefaultApplyOutcomeTimeout.ShouldBeLessThan(DeployTimings.ApplyDeadline);
|
||||
}
|
||||
|
||||
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") { }
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -42,7 +42,7 @@ public sealed class DriverHostActorFetchAndCacheBootstrapTests : RuntimeActorTes
|
||||
var cached = new CachedDeploymentArtifact(
|
||||
DeploymentId.NewId().ToString(), CachedRev.Value, blob, DateTimeOffset.UtcNow.AddHours(-1));
|
||||
var cache = new StubArtifactCache(cached);
|
||||
var publishProbe = CreateTestProbe();
|
||||
var publishProbe = this.CreateCleanOutcomePublishProbe();
|
||||
|
||||
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||||
new ThrowingDbFactory(), TestNode, ackRouter: null,
|
||||
|
||||
+3
-3
@@ -42,7 +42,7 @@ public sealed class DriverHostActorFetchAndCacheUnreadableTests : RuntimeActorTe
|
||||
[RevA.Value] = Artifact(),
|
||||
[RevB.Value] = null,
|
||||
});
|
||||
var publishProbe = CreateTestProbe();
|
||||
var publishProbe = this.CreateCleanOutcomePublishProbe();
|
||||
var coordinator = CreateTestProbe();
|
||||
|
||||
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||||
@@ -72,7 +72,7 @@ public sealed class DriverHostActorFetchAndCacheUnreadableTests : RuntimeActorTe
|
||||
// empty blob handed to it directly: ReconcileDriversFromBlob's #485 guard fails the apply.
|
||||
var db = NewInMemoryDbFactory();
|
||||
var fetcher = new MapFetcher(new Dictionary<string, byte[]?> { [RevA.Value] = Array.Empty<byte>() });
|
||||
var publishProbe = CreateTestProbe();
|
||||
var publishProbe = this.CreateCleanOutcomePublishProbe();
|
||||
var coordinator = CreateTestProbe();
|
||||
|
||||
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||||
@@ -95,7 +95,7 @@ public sealed class DriverHostActorFetchAndCacheUnreadableTests : RuntimeActorTe
|
||||
// A truncated/corrupt cached artifact surfaces as GetCurrentUnkeyedAsync == null (the cache's
|
||||
// integrity check is deliberately indistinguishable from a miss). Boot must land
|
||||
// Steady-no-revision and apply NOTHING — never a partial address space.
|
||||
var publishProbe = CreateTestProbe();
|
||||
var publishProbe = this.CreateCleanOutcomePublishProbe();
|
||||
|
||||
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||||
new ThrowingDbFactory(), TestNode, ackRouter: null,
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ public sealed class DriverHostActorHistoryWriterTests : RuntimeActorTestBase
|
||||
// A publish actor MUST be wired for SpawnVirtualTagHost to spawn the real host (its ctor
|
||||
// requires a non-null sink). No virtualTagHostOverride ⇒ the real VirtualTagHostActor is
|
||||
// spawned with _historyWriter threaded in.
|
||||
var publish = CreateTestProbe();
|
||||
var publish = this.CreateCleanOutcomePublishProbe();
|
||||
var writer = new CapturingHistoryWriter();
|
||||
|
||||
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||||
|
||||
+1
-1
@@ -556,7 +556,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
|
||||
{
|
||||
var coordinator = CreateTestProbe();
|
||||
var publish = CreateTestProbe();
|
||||
var publish = this.CreateCleanOutcomePublishProbe();
|
||||
var mux = CreateTestProbe();
|
||||
var vtHost = CreateTestProbe();
|
||||
|
||||
|
||||
+1
-1
@@ -251,7 +251,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId dep, int driverMemberCount)
|
||||
{
|
||||
var coordinator = CreateTestProbe();
|
||||
var publish = CreateTestProbe();
|
||||
var publish = this.CreateCleanOutcomePublishProbe();
|
||||
var mux = CreateTestProbe();
|
||||
var vtHost = CreateTestProbe();
|
||||
|
||||
|
||||
@@ -226,7 +226,7 @@ public sealed class DriverHostActorTests : RuntimeActorTestBase
|
||||
seeded.Length.ShouldBeGreaterThan(0);
|
||||
|
||||
var coordinator = CreateTestProbe();
|
||||
var publish = CreateTestProbe();
|
||||
var publish = this.CreateCleanOutcomePublishProbe();
|
||||
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||||
db, TestNode, coordinator.Ref,
|
||||
localRoles: new HashSet<string> { "driver" },
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #601 — since the deploy ack waits for <see cref="OpcUaPublishActor"/>'s
|
||||
/// <see cref="OpcUaPublishActor.RebuildCompleted"/> reply, a bare <see cref="TestProbe"/> standing in for the
|
||||
/// publish actor would hold every Applied ack until the outcome timeout (and then ack it degraded). Tests
|
||||
/// that are not ABOUT the ack's honesty use this probe instead: it still records every message exactly
|
||||
/// like a plain probe (the auto-pilot runs in addition to queueing), and answers each
|
||||
/// <see cref="OpcUaPublishActor.RebuildAddressSpace"/> that asks for an outcome with a CLEAN one — the
|
||||
/// reply the real publish actor gives a clean apply.
|
||||
/// </summary>
|
||||
public static class PublishProbes
|
||||
{
|
||||
/// <summary>Creates a publish-actor probe that reports a clean address-space outcome for every rebuild
|
||||
/// carrying <see cref="OpcUaPublishActor.RebuildAddressSpace.ReportOutcome"/>.</summary>
|
||||
/// <param name="kit">The calling TestKit.</param>
|
||||
/// <returns>The probe.</returns>
|
||||
public static TestProbe CreateCleanOutcomePublishProbe(this TestKitBase kit)
|
||||
{
|
||||
var probe = kit.CreateTestProbe();
|
||||
probe.SetAutoPilot(new DelegateAutoPilot((sender, message) =>
|
||||
{
|
||||
if (message is OpcUaPublishActor.RebuildAddressSpace { ReportOutcome: true } rebuild)
|
||||
sender.Tell(new OpcUaPublishActor.RebuildCompleted(
|
||||
rebuild.Correlation, rebuild.DeploymentId, Degraded: false, DegradedReason: null));
|
||||
return AutoPilot.KeepRunning;
|
||||
}));
|
||||
return probe;
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,7 @@ public static class V3ArtifactSeeder
|
||||
TimeSpan? reprovisionBackoff = null,
|
||||
TimeSpan? commsRecoveryAuditDelay = null)
|
||||
{
|
||||
var publish = kit.CreateTestProbe();
|
||||
var publish = kit.CreateCleanOutcomePublishProbe();
|
||||
var (actor, mux, coordinator) =
|
||||
Spawn(kit, db, localNode, publish.Ref, driverFactory, reprovisionBackoff, commsRecoveryAuditDelay);
|
||||
|
||||
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
using System.Text.Json;
|
||||
using Akka.Actor;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #601 — the deploy ack is sent only once the address-space apply outcome is known, so the
|
||||
/// publish actor must REPORT that outcome when asked (<see cref="OpcUaPublishActor.RebuildAddressSpace.ReportOutcome"/>):
|
||||
/// clean for a clean apply, degraded for swallowed per-node sink faults or a pipeline throw, and nothing
|
||||
/// at all when the caller did not ask (boot/restore paths re-ack nothing).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// In the <c>opcua-publish-meters</c> collection because the degraded cases increment
|
||||
/// <c>otopcua.opcua.apply.failed</c>, which the sibling classes assert stays at zero.
|
||||
/// </remarks>
|
||||
[Collection("opcua-publish-meters")]
|
||||
public sealed class OpcUaPublishActorApplyOutcomeTests : RuntimeActorTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void A_clean_apply_reports_a_clean_outcome_to_the_requester()
|
||||
{
|
||||
var sink = new FaultingSink(throwOnEnsureFolder: false);
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier));
|
||||
var correlation = CorrelationId.NewId();
|
||||
var deployment = DeploymentId.NewId();
|
||||
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(
|
||||
correlation, deployment, BuildEquipmentArtifact("eq-1"), ReportOutcome: true));
|
||||
|
||||
var done = ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PresenceBudget);
|
||||
done.Correlation.ShouldBe(correlation);
|
||||
done.DeploymentId.ShouldBe(deployment);
|
||||
done.Degraded.ShouldBeFalse();
|
||||
done.DegradedReason.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_degraded_non_rebuild_apply_reports_degraded_with_the_failed_node_count()
|
||||
{
|
||||
// Every EnsureFolder throws — the applier swallows each and counts it, the classic degraded apply
|
||||
// that leaves _lastApplied un-advanced and used to seal the deployment green.
|
||||
var sink = new FaultingSink(throwOnEnsureFolder: true);
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier));
|
||||
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(
|
||||
CorrelationId.NewId(), DeploymentId.NewId(), BuildEquipmentArtifact("eq-1"), ReportOutcome: true));
|
||||
|
||||
var done = ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PresenceBudget);
|
||||
done.Degraded.ShouldBeTrue();
|
||||
done.DegradedReason.ShouldNotBeNull();
|
||||
done.DegradedReason.ShouldContain("failedNodes=");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_pipeline_that_throws_reports_degraded()
|
||||
{
|
||||
var sink = new FaultingSink(throwOnEnsureFolder: false);
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier));
|
||||
|
||||
// Non-empty bytes that are not JSON: DeploymentArtifact.Open throws inside the rebuild pipeline.
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(
|
||||
CorrelationId.NewId(), DeploymentId.NewId(), "not json"u8.ToArray(), ReportOutcome: true));
|
||||
|
||||
var done = ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PresenceBudget);
|
||||
done.Degraded.ShouldBeTrue();
|
||||
done.DegradedReason.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unchanged_composition_reports_clean()
|
||||
{
|
||||
var sink = new FaultingSink(throwOnEnsureFolder: false);
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier));
|
||||
var artifact = BuildEquipmentArtifact("eq-1");
|
||||
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), DeploymentId.NewId(), artifact, ReportOutcome: true));
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PresenceBudget).Degraded.ShouldBeFalse();
|
||||
|
||||
// Same composition again ⇒ empty plan ⇒ nothing to apply, nothing degraded.
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), DeploymentId.NewId(), artifact, ReportOutcome: true));
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PresenceBudget).Degraded.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_ReportOutcome_nothing_is_replied()
|
||||
{
|
||||
var sink = new FaultingSink(throwOnEnsureFolder: true);
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier));
|
||||
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(
|
||||
CorrelationId.NewId(), DeploymentId.NewId(), BuildEquipmentArtifact("eq-1")));
|
||||
|
||||
// Positive control first: a follow-up REQUESTED outcome must arrive, which proves the first
|
||||
// message was fully processed before the absence assertion is read.
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(
|
||||
CorrelationId.NewId(), DeploymentId.NewId(), BuildEquipmentArtifact("eq-2"), ReportOutcome: true));
|
||||
var only = ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PresenceBudget);
|
||||
only.Degraded.ShouldBeTrue();
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Redeploying_the_same_composition_after_a_degraded_REBUILD_repairs_it_instead_of_reporting_clean()
|
||||
{
|
||||
// A degraded REBUILD advances _lastApplied (deliberately — see HandleRebuildCore), so a re-deploy of
|
||||
// the same revision diffs to an EMPTY plan. Reporting that empty plan as clean would let the
|
||||
// "re-deploy re-applies" repair path (#601) seal green over an address space still known partial.
|
||||
var sink = new FaultingSink(throwOnEnsureFolder: false);
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier));
|
||||
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(
|
||||
CorrelationId.NewId(), DeploymentId.NewId(), BuildEquipmentArtifact("eq-1", "First"), ReportOutcome: true));
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PresenceBudget).Degraded.ShouldBeFalse();
|
||||
|
||||
// A changed equipment row routes to a full rebuild; its re-materialise faults.
|
||||
sink.ThrowOnEnsureFolder = true;
|
||||
var changed = BuildEquipmentArtifact("eq-1", "Second");
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), DeploymentId.NewId(), changed, ReportOutcome: true));
|
||||
var degraded = ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PresenceBudget);
|
||||
degraded.Degraded.ShouldBeTrue();
|
||||
sink.RebuildCount.ShouldBe(1);
|
||||
|
||||
// Same revision again, sink still faulting: must NOT claim clean.
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), DeploymentId.NewId(), changed, ReportOutcome: true));
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PresenceBudget).Degraded.ShouldBeTrue();
|
||||
|
||||
// Fault gone: the same revision re-materialises the whole composition and only THEN reports clean.
|
||||
sink.ThrowOnEnsureFolder = false;
|
||||
var foldersBefore = sink.EnsureFolderCount;
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), DeploymentId.NewId(), changed, ReportOutcome: true));
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PresenceBudget).Degraded.ShouldBeFalse();
|
||||
sink.EnsureFolderCount.ShouldBeGreaterThan(foldersBefore);
|
||||
|
||||
// And now it is genuinely clean, so a further identical deploy is the ordinary empty no-op.
|
||||
var foldersAfterRepair = sink.EnsureFolderCount;
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), DeploymentId.NewId(), changed, ReportOutcome: true));
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PresenceBudget).Degraded.ShouldBeFalse();
|
||||
sink.EnsureFolderCount.ShouldBe(foldersAfterRepair);
|
||||
}
|
||||
|
||||
private static byte[] BuildEquipmentArtifact(string equipmentId) => BuildEquipmentArtifact(equipmentId, equipmentId);
|
||||
|
||||
private static byte[] BuildEquipmentArtifact(string equipmentId, string name) =>
|
||||
JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
UnsAreas = new[] { new { UnsAreaId = "area-1", ClusterId = "c1", Name = "Area" } },
|
||||
UnsLines = new[] { new { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "Line" } },
|
||||
Equipment = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
EquipmentId = equipmentId,
|
||||
DriverInstanceId = (string?)null,
|
||||
UnsLineId = "line-1",
|
||||
Name = name,
|
||||
MachineCode = equipmentId.ToUpperInvariant(),
|
||||
},
|
||||
},
|
||||
DriverInstances = Array.Empty<object>(),
|
||||
Namespaces = Array.Empty<object>(),
|
||||
Tags = Array.Empty<object>(),
|
||||
ScriptedAlarms = Array.Empty<object>(),
|
||||
});
|
||||
|
||||
/// <summary>A sink that optionally throws from <c>EnsureFolder</c> (a swallowed per-node fault).</summary>
|
||||
private sealed class FaultingSink(bool throwOnEnsureFolder) : IOpcUaAddressSpaceSink
|
||||
{
|
||||
public bool ThrowOnEnsureFolder { get; set; } = throwOnEnsureFolder;
|
||||
public int EnsureFolderCount;
|
||||
public int RebuildCount;
|
||||
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) { }
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
{
|
||||
EnsureFolderCount++;
|
||||
if (ThrowOnEnsureFolder) throw new InvalidOperationException("simulated sink fault");
|
||||
}
|
||||
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) { }
|
||||
public void RebuildAddressSpace() => RebuildCount++;
|
||||
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") { }
|
||||
}
|
||||
}
|
||||
+18
-4
@@ -880,7 +880,10 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
/// <c>_lastApplied</c>. A rebuild already re-materialised the whole composition, and the applier's
|
||||
/// one-way rebuild ratchet is the resync mechanism for anything it could not place — holding the
|
||||
/// composition back here would make every subsequent deploy re-diff the entire address space as added.
|
||||
/// Proven by the third apply of the same composition being an empty-plan no-op.
|
||||
/// Proven by the third apply of the same composition NOT rebuilding again (it diffs to an empty plan).
|
||||
/// Gitea #601 review: that empty plan is no longer a silent no-op, though — the previous apply was a
|
||||
/// degraded rebuild, so the identical re-deploy re-ensures the whole composition (idempotently, no
|
||||
/// rebuild) and thereby repairs the node the rebuild could not place.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rebuild_with_failures_still_advances_last_applied()
|
||||
@@ -904,13 +907,24 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
var afterSecond = sink.Calls.Count;
|
||||
|
||||
// Re-deploy the SAME composition. _lastApplied advanced despite the failure, so this diffs to an
|
||||
// empty plan and adds no sink calls at all.
|
||||
// empty plan — no second rebuild (an un-advanced composition would re-diff ChangedEquipment and
|
||||
// rebuild again) — but the degraded rebuild is remembered, so the failed variable is re-ensured.
|
||||
sink.FailEnsureVariableFor = null;
|
||||
var dep3 = SeedUnsRawDeployment(db, equipmentName: "station1-renamed", referencedTag: "a");
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep3)));
|
||||
Thread.Sleep(300);
|
||||
AwaitAssert(
|
||||
() => sink.Calls.Skip(afterSecond).ShouldContain("EV:Plant/Modbus/dev1/b"),
|
||||
duration: PresenceBudget);
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
|
||||
sink.Calls.Count.ShouldBe(afterSecond);
|
||||
// Repaired ⇒ genuinely clean now, so a FOURTH identical deploy is the ordinary empty-plan no-op.
|
||||
Thread.Sleep(200);
|
||||
var afterRepair = sink.Calls.Count;
|
||||
var dep4 = SeedUnsRawDeployment(db, equipmentName: "station1-renamed", referencedTag: "a");
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep4)));
|
||||
Thread.Sleep(300);
|
||||
sink.Calls.Count.ShouldBe(afterRepair);
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Seal a deployment whose raw tree is <c>Plant/Modbus/dev1</c> carrying one tag per name in
|
||||
|
||||
+1
-1
@@ -232,7 +232,7 @@ public sealed class PublishSeamEmitsToHubTests : RuntimeActorTestBase
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId, ITelemetryLocalHub hub)
|
||||
{
|
||||
var coordinator = CreateTestProbe();
|
||||
var publish = CreateTestProbe();
|
||||
var publish = this.CreateCleanOutcomePublishProbe();
|
||||
var mux = CreateTestProbe();
|
||||
var vtHost = CreateTestProbe();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user