fe7603ec6e
A degraded rebuild advances _lastApplied (deliberately), so re-deploying the same revision diffed to an empty plan and reported a CLEAN outcome - the #601 're-deploy re-applies' repair path sealed green over an address space still known partial. The publish actor now remembers that the last advance was degraded and, on an empty plan, re-ensures the whole composition (idempotent, no rebuild), reporting clean only when that pass is clean. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
201 lines
11 KiB
C#
201 lines
11 KiB
C#
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") { }
|
|
}
|
|
}
|