Merge branch 'fix/r3-02-stale-good-family' — R3-02: one quality contract at four seams — comms loss marks raw+UNS value variables BadNoCommunication (WriteStatuses sink member, recovery audit), Calculation + VT hold on Bad / worst-of sub-code / (value,status) dedup, MQTT initial push + ConnectionLost stale, Sparkplug birth replay (archreview 03/S15, 05/STAB-19, 05/STAB-18, 02/S14)
This commit is contained in:
+93
@@ -7,6 +7,7 @@ using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Engines;
|
||||
|
||||
@@ -539,6 +540,98 @@ public sealed class RoslynVirtualTagEvaluatorTests
|
||||
$"{starved}/{totalEvaluations} evaluations missed the 5s budget");
|
||||
}
|
||||
|
||||
// ── input quality reaches the script (archreview 02/S14) ────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// A script reading <c>ctx.GetTag(path).StatusCode</c> sees the dependency's <b>real</b> status.
|
||||
/// The read cache used to stamp every input <c>Good</c>, so a script could not detect a degraded
|
||||
/// input at all — "broken input looks Good", the other door onto the same lie 02/S13 closed for
|
||||
/// broken scripts.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Evaluate_read_cache_carries_the_inputs_real_status_code()
|
||||
{
|
||||
const uint uncertain = 0x40000000u;
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||
var stamp = new DateTime(2026, 8, 20, 9, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var result = sut.Evaluate(
|
||||
virtualTagId: "vt-quality",
|
||||
expression: "return (long)ctx.GetTag(\"a\").StatusCode;",
|
||||
dependencies: new Dictionary<string, DataValueSnapshot>
|
||||
{
|
||||
["a"] = new(7, uncertain, stamp, stamp),
|
||||
});
|
||||
|
||||
result.Success.ShouldBeTrue(result.Reason);
|
||||
result.Value.ShouldBe((long)uncertain);
|
||||
}
|
||||
|
||||
/// <summary>The dependency's own source timestamp reaches the script, not evaluation time.</summary>
|
||||
[Fact]
|
||||
public void Evaluate_read_cache_carries_the_inputs_real_source_timestamp()
|
||||
{
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||
var stamp = new DateTime(2026, 8, 20, 9, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var result = sut.Evaluate(
|
||||
virtualTagId: "vt-stamp",
|
||||
expression: "return ctx.GetTag(\"a\").SourceTimestampUtc;",
|
||||
dependencies: new Dictionary<string, DataValueSnapshot>
|
||||
{
|
||||
["a"] = new(7, 0u, stamp, DateTime.UtcNow),
|
||||
});
|
||||
|
||||
result.Success.ShouldBeTrue(result.Reason);
|
||||
result.Value.ShouldBe(stamp);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The value-only overload keeps working unchanged — it is what the interface's default
|
||||
/// implementation and the five test fakes still use, which is the whole reason the new overload is
|
||||
/// default-implemented rather than abstract.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void The_value_only_overload_still_evaluates()
|
||||
{
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||
|
||||
var result = sut.Evaluate(
|
||||
virtualTagId: "vt-values",
|
||||
expression: "return (int)ctx.GetTag(\"a\").Value + 1;",
|
||||
dependencies: new Dictionary<string, object?> { ["a"] = 41 });
|
||||
|
||||
result.Success.ShouldBeTrue(result.Reason);
|
||||
result.Value.ShouldBe(42);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A fake implementing ONLY the value-only member still answers the snapshot overload, through the
|
||||
/// interface's default projection. Without that default, adding the overload would have been a
|
||||
/// breaking change for every existing <see cref="IVirtualTagEvaluator"/> implementation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void The_snapshot_overload_falls_back_to_a_value_only_implementation()
|
||||
{
|
||||
IVirtualTagEvaluator fake = new ValueOnlyEvaluator();
|
||||
|
||||
var result = fake.Evaluate("vt-fake", "ignored", new Dictionary<string, DataValueSnapshot>
|
||||
{
|
||||
["a"] = new(41, 0u, DateTime.UtcNow, DateTime.UtcNow),
|
||||
});
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Value.ShouldBe(41);
|
||||
}
|
||||
|
||||
/// <summary>An evaluator predating the snapshot overload: implements the value-only member only.</summary>
|
||||
private sealed class ValueOnlyEvaluator : IVirtualTagEvaluator
|
||||
{
|
||||
public VirtualTagEvalResult Evaluate(
|
||||
string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
|
||||
=> VirtualTagEvalResult.Ok(dependencies["a"]);
|
||||
}
|
||||
|
||||
/// <summary>Reads the count of the private compiled-script cache via reflection. The cache is a
|
||||
/// <c>CompiledScriptCache<,></c> which exposes a <c>Count</c> property (not <c>ICollection</c>).</summary>
|
||||
private static int CompiledCacheCount(RoslynVirtualTagEvaluator sut)
|
||||
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="OtOpcUaNodeManager.WriteStatuses"/> — the status-only batch write behind the
|
||||
/// comms-loss annotation for value variables (archreview 03/S15).
|
||||
///
|
||||
/// <para><b>Keeping the value is the whole point.</b> The host holds no value cache (v3's
|
||||
/// single-source design), so "this driver has stopped communicating, but here is what it last
|
||||
/// said" can only be expressed at the node, where the value already lives. The Part 8
|
||||
/// last-known-value convention is what a SCADA client expects, and the interface's default
|
||||
/// implementation — which nulls the value because a value-less sink has nothing else to write —
|
||||
/// would silently erase it if the SDK sink ever stopped overriding it.</para>
|
||||
///
|
||||
/// <para><b>Never creates.</b> A status carries nothing to create a node from, and the caller's
|
||||
/// per-driver index can legitimately name a node a surgical remove has just dropped. A missing id
|
||||
/// is skipped, not materialised — the opposite of <see cref="OtOpcUaNodeManager.WriteValues"/>,
|
||||
/// which lazily creates.</para>
|
||||
///
|
||||
/// <para>Boots a real <see cref="OtOpcUaSdkServer"/>, like
|
||||
/// <see cref="NodeManagerBatchWriteTests"/>, so the assertions read the SDK node state a client
|
||||
/// would receive.</para>
|
||||
/// </summary>
|
||||
public sealed class OtOpcUaNodeManagerWriteStatusesTests : IDisposable
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private readonly string _pkiRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"otopcua-writestatuses-{Guid.NewGuid():N}");
|
||||
|
||||
/// <summary>
|
||||
/// The contract: value retained, status + timestamp replaced. Asserted on a node that was first
|
||||
/// written a real value, because "value retained" is unfalsifiable on a node that never had one.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WriteStatuses_replaces_status_and_timestamp_but_keeps_the_value()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var t1 = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
var t2 = new DateTime(2026, 8, 20, 10, 5, 0, DateTimeKind.Utc);
|
||||
|
||||
nm.EnsureVariable("Plant/Modbus/sim/HR5", parentFolderNodeId: null, displayName: "HR5",
|
||||
dataType: "Int32", writable: false, realm: AddressSpaceRealm.Raw);
|
||||
nm.WriteValue("Plant/Modbus/sim/HR5", 42, OpcUaStatus.Good, t1, AddressSpaceRealm.Raw);
|
||||
|
||||
nm.WriteStatuses([new StatusWrite("Plant/Modbus/sim/HR5", OpcUaStatus.BadNoCommunication, t2, AddressSpaceRealm.Raw)]);
|
||||
|
||||
var v = nm.TryGetVariable("Plant/Modbus/sim/HR5", AddressSpaceRealm.Raw);
|
||||
v.ShouldNotBeNull();
|
||||
v!.Value.ShouldBe(42, "the last known value must survive the comms-loss annotation");
|
||||
v.StatusCode.ShouldBe((StatusCode)StatusCodes.BadNoCommunication);
|
||||
v.StatusCode.ShouldNotBe((StatusCode)StatusCodes.Bad, "the sub-code reaches the SDK verbatim (#519)");
|
||||
v.Timestamp.ShouldBe(t2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Realm-qualified, like every other node-naming sink call: the same <c>s=</c> id in the other
|
||||
/// namespace is a different node and must be untouched.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WriteStatuses_only_touches_the_named_realm()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var ts = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
const string sharedId = "Shared/both/realms";
|
||||
|
||||
nm.WriteValue(sharedId, 1, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
|
||||
nm.WriteValue(sharedId, 2, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns);
|
||||
|
||||
nm.WriteStatuses([new StatusWrite(sharedId, OpcUaStatus.BadNoCommunication, ts, AddressSpaceRealm.Raw)]);
|
||||
|
||||
nm.TryGetVariable(sharedId, AddressSpaceRealm.Raw)!.StatusCode
|
||||
.ShouldBe((StatusCode)StatusCodes.BadNoCommunication);
|
||||
nm.TryGetVariable(sharedId, AddressSpaceRealm.Uns)!.StatusCode
|
||||
.ShouldBe((StatusCode)StatusCodes.Good);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An unknown NodeId is a no-op: no node is created, nothing throws, and the rest of the batch
|
||||
/// still lands. A create here would materialise a node nobody authored, holding no value, from a
|
||||
/// driver-index entry that is stale precisely because the node was removed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_unknown_node_id_is_skipped_not_created()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var ts = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
nm.WriteValue("Plant/known", 7, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
|
||||
var before = nm.VariableCount;
|
||||
|
||||
Should.NotThrow(() => nm.WriteStatuses(
|
||||
[
|
||||
new StatusWrite("Plant/does/not/exist", OpcUaStatus.BadNoCommunication, ts, AddressSpaceRealm.Raw),
|
||||
new StatusWrite("Plant/known", OpcUaStatus.BadNoCommunication, ts, AddressSpaceRealm.Raw),
|
||||
]));
|
||||
|
||||
nm.VariableCount.ShouldBe(before, "a status write must never create a node");
|
||||
nm.TryGetVariable("Plant/does/not/exist", AddressSpaceRealm.Raw).ShouldBeNull();
|
||||
nm.TryGetVariable("Plant/known", AddressSpaceRealm.Raw)!.StatusCode
|
||||
.ShouldBe((StatusCode)StatusCodes.BadNoCommunication);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A malformed item does not drop the tail — the same per-item isolation contract
|
||||
/// <see cref="OtOpcUaNodeManager.WriteValues"/> carries, reported through the same
|
||||
/// <see cref="ValueWriteBatchException"/> aggregate.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_failing_item_neither_drops_the_tail_nor_hides_which_node_failed()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var ts = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
nm.WriteValue("Plant/first", 1, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
|
||||
nm.WriteValue("Plant/third", 3, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
|
||||
|
||||
var ex = Should.Throw<ValueWriteBatchException>(() => nm.WriteStatuses(
|
||||
[
|
||||
new StatusWrite("Plant/first", OpcUaStatus.BadNoCommunication, ts, AddressSpaceRealm.Raw),
|
||||
new StatusWrite(string.Empty, OpcUaStatus.BadNoCommunication, ts, AddressSpaceRealm.Raw),
|
||||
new StatusWrite("Plant/third", OpcUaStatus.BadNoCommunication, ts, AddressSpaceRealm.Raw),
|
||||
]));
|
||||
|
||||
ex.FailedCount.ShouldBe(1);
|
||||
ex.SucceededCount.ShouldBe(2);
|
||||
ex.FirstFailedNodeId.ShouldBe(string.Empty);
|
||||
|
||||
nm.TryGetVariable("Plant/first", AddressSpaceRealm.Raw)!.Value.ShouldBe(1);
|
||||
nm.TryGetVariable("Plant/third", AddressSpaceRealm.Raw)!.StatusCode
|
||||
.ShouldBe((StatusCode)StatusCodes.BadNoCommunication, "the item after the failing one must not be dropped");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An empty batch neither throws nor touches the address space.</summary>
|
||||
[Fact]
|
||||
public async Task Empty_batch_is_a_no_op()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var before = nm.VariableCount;
|
||||
|
||||
Should.NotThrow(() => nm.WriteStatuses([]));
|
||||
|
||||
nm.VariableCount.ShouldBe(before);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
|
||||
{
|
||||
var host = new OpcUaApplicationHost(
|
||||
new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "OtOpcUa.WriteStatusesTest",
|
||||
ApplicationUri = $"urn:OtOpcUa.WriteStatusesTest:{Guid.NewGuid():N}",
|
||||
OpcUaPort = AllocateFreePort(),
|
||||
PublicHostname = "localhost",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance);
|
||||
|
||||
var server = new OtOpcUaSdkServer();
|
||||
await host.StartAsync(server, Ct);
|
||||
return (host, server);
|
||||
}
|
||||
|
||||
private static int AllocateFreePort()
|
||||
{
|
||||
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
/// <summary>Cleans up the PKI root directory.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_pkiRoot))
|
||||
{
|
||||
try { Directory.Delete(_pkiRoot, recursive: true); }
|
||||
catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
+298
@@ -0,0 +1,298 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using Akka.Actor;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
using static ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness.V3ArtifactSeeder;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Archreview <b>03/S15</b> — the value-node half of the comms-loss fan-out. Until this, a driver
|
||||
/// declared comms-lost annotated its Part 9 conditions and pushed a synthetic Bad into the dependency
|
||||
/// mux, but its <b>raw and UNS value variables</b> — the product's external surface — got nothing: an
|
||||
/// OPC UA client subscribed to a value node saw the last value at <b>Good</b> quality for the whole
|
||||
/// outage, for every driver whose failure shape is "reader stops producing" rather than "per-tag Bad
|
||||
/// snapshot".
|
||||
///
|
||||
/// <para><b>Status-only, value retained.</b> The write goes through
|
||||
/// <see cref="OpcUaPublishActor.AttributeStatusBatchUpdate"/> so the node keeps its last known value
|
||||
/// under <c>BadNoCommunication</c> (OPC UA Part 8's convention). The host holds no value cache — v3's
|
||||
/// single-source design — so only the node itself can retain it.</para>
|
||||
///
|
||||
/// <para><b>Nothing is published on reconnect</b>, deliberately: publishing Good would assert a
|
||||
/// quality this actor has not observed. Recovery is the driver's own re-publish (the poll engine's
|
||||
/// <c>forceRaise</c> on a fresh subscription, or the push-model initial push). That contract is
|
||||
/// end-to-end tested in <see cref="DriverHostCommsLossRecoveryTests"/>.</para>
|
||||
/// </summary>
|
||||
public sealed class DriverHostActorCommsLossValueNodesTests : RuntimeActorTestBase
|
||||
{
|
||||
private static readonly NodeId TestNode = NodeId.Parse("driver-commsloss-values");
|
||||
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('c', 64));
|
||||
private static readonly DateTime TsA = new(2026, 8, 20, 9, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
/// <summary>Audit delay used by the T9 cases — long enough that a same-turn prune wins the race,
|
||||
/// short enough to observe.</summary>
|
||||
private static readonly TimeSpan AuditDelay = TimeSpan.FromMilliseconds(400);
|
||||
|
||||
// Distinct driver ids per metered test: the meter tag is what the recorder filters on, and a
|
||||
// MeterListener sees every measurement in the process — including a parallel test's (#503).
|
||||
private const string SilentDriver = "drv-audit-silent";
|
||||
private const string RepublishingDriver = "drv-audit-republish";
|
||||
private const string PartialDriver = "drv-audit-partial";
|
||||
|
||||
/// <summary>
|
||||
/// A disconnect produces ONE status batch naming exactly the disconnected driver's raw nodes plus
|
||||
/// every referencing UNS mirror, all <c>BadNoCommunication</c>. The second driver's ref is the
|
||||
/// control: a fan-out that walked every value ref on the node instead of the per-driver index would
|
||||
/// mark a perfectly healthy driver's nodes Bad, and no assertion about the first driver's nodes
|
||||
/// would notice.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Disconnect_marks_the_drivers_raw_and_uns_value_nodes_BadNoCommunication()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedV3Deployment(db, RevA,
|
||||
rawTags: new[]
|
||||
{
|
||||
(Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false),
|
||||
(Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "temp", DataType: "Double", Writable: false),
|
||||
(Driver: "drv-2", DriverName: "S7", Device: "dev2", Tag: "other", DataType: "Double", Writable: false),
|
||||
},
|
||||
unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) });
|
||||
|
||||
var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: false));
|
||||
|
||||
var batch = publish.ExpectMsg<OpcUaPublishActor.AttributeStatusBatchUpdate>(PresenceBudget);
|
||||
batch.Items.Select(i => i.NodeId).ShouldBe(new[]
|
||||
{
|
||||
"Plant/Modbus/dev1/speed",
|
||||
"filling/line1/station1/speed",
|
||||
"Plant/Modbus/dev1/temp",
|
||||
}, ignoreOrder: true);
|
||||
|
||||
batch.Items.ShouldAllBe(i => i.StatusCode == OpcUaStatus.BadNoCommunication);
|
||||
batch.Items.Single(i => i.NodeId == "Plant/Modbus/dev1/speed").Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||
batch.Items.Single(i => i.NodeId == "filling/line1/station1/speed").Realm.ShouldBe(AddressSpaceRealm.Uns);
|
||||
|
||||
// The control: the OTHER driver's node is untouched by drv-1's outage.
|
||||
batch.Items.ShouldNotContain(i => i.NodeId == "Plant/S7/dev2/other");
|
||||
|
||||
// ONE batch, not one message per node.
|
||||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A reconnect publishes <b>nothing</b> to the value nodes. Writing Good here would claim a quality
|
||||
/// the host has not observed — the device may be answering the transport and still returning
|
||||
/// garbage — and it would race the driver's own first real value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Reconnect_publishes_no_value_or_status_message()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedV3Deployment(db, RevA,
|
||||
rawTags: new[]
|
||||
{
|
||||
(Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false),
|
||||
},
|
||||
unsRefs: Array.Empty<(string, string, string, string, string?)>());
|
||||
|
||||
var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: false));
|
||||
publish.ExpectMsg<OpcUaPublishActor.AttributeStatusBatchUpdate>(PresenceBudget);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: true));
|
||||
|
||||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// T9 — the recovery audit (the dedup-trap tripwire)
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// The host publishes nothing Good on reconnect, so the only way a stuck-Bad node becomes visible
|
||||
/// is this audit: refs still latched some time after the driver reconnected are logged and metered.
|
||||
/// Here the driver re-publishes nothing at all — the pre-STAB-18 MQTT shape, and the exact failure
|
||||
/// any future push driver without an initial push would produce.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Refs_still_latched_after_a_reconnect_are_logged_and_metered()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedV3Deployment(db, RevA,
|
||||
rawTags: new[]
|
||||
{
|
||||
(Driver: SilentDriver, DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false),
|
||||
(Driver: SilentDriver, DriverName: "Modbus", Device: "dev1", Tag: "temp", DataType: "Double", Writable: false),
|
||||
},
|
||||
unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) });
|
||||
|
||||
var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, commsRecoveryAuditDelay: AuditDelay);
|
||||
using var meter = new MeterRecorder("otopcua.driver.comms_latched_refs");
|
||||
|
||||
actor.Tell(new DriverInstanceActor.ConnectivityChanged(SilentDriver, Connected: false));
|
||||
publish.ExpectMsg<OpcUaPublishActor.AttributeStatusBatchUpdate>(PresenceBudget);
|
||||
|
||||
EventFilter.Warning(contains: "still BadNoCommunication").ExpectOne(TimeSpan.FromSeconds(10), () =>
|
||||
actor.Tell(new DriverInstanceActor.ConnectivityChanged(SilentDriver, Connected: true)));
|
||||
|
||||
// Two REFS, not three nodes: the latch is keyed by RawPath, and the UNS mirror shares its
|
||||
// backing tag's ref — the operator is being told how many tags did not come back, not how many
|
||||
// address-space nodes they project onto.
|
||||
AwaitCondition(() => meter.WithTag("driver", SilentDriver) > 0, TimeSpan.FromSeconds(5));
|
||||
meter.SumWithTag("driver", SilentDriver).ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>The control.</b> A driver that DOES re-publish every latched ref on reconnect trips nothing —
|
||||
/// without this, the test above would pass against an audit that fires unconditionally, which would
|
||||
/// make the Warning noise rather than signal.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_driver_that_republishes_every_ref_trips_no_audit()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedV3Deployment(db, RevA,
|
||||
rawTags: new[]
|
||||
{
|
||||
(Driver: RepublishingDriver, DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false),
|
||||
(Driver: RepublishingDriver, DriverName: "Modbus", Device: "dev1", Tag: "temp", DataType: "Double", Writable: false),
|
||||
},
|
||||
unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) });
|
||||
|
||||
var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, commsRecoveryAuditDelay: AuditDelay);
|
||||
using var meter = new MeterRecorder("otopcua.driver.comms_latched_refs");
|
||||
|
||||
actor.Tell(new DriverInstanceActor.ConnectivityChanged(RepublishingDriver, Connected: false));
|
||||
publish.ExpectMsg<OpcUaPublishActor.AttributeStatusBatchUpdate>(PresenceBudget);
|
||||
|
||||
EventFilter.Warning(contains: "still BadNoCommunication").Expect(0, TimeSpan.FromSeconds(5), () =>
|
||||
{
|
||||
actor.Tell(new DriverInstanceActor.ConnectivityChanged(RepublishingDriver, Connected: true));
|
||||
|
||||
// The driver's own re-publish — identical values, which is precisely the case a value-only
|
||||
// dedup would swallow; here it is the driver that force-raised, so both refs are pruned.
|
||||
actor.Tell(new DriverInstanceActor.AttributeValueBatchPublished(RepublishingDriver, new[]
|
||||
{
|
||||
new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/speed", 42.0, OpcUaStatus.Good, TsA),
|
||||
new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/temp", 7.5, OpcUaStatus.Good, TsA),
|
||||
}));
|
||||
publish.ExpectMsg<OpcUaPublishActor.AttributeValueBatchUpdate>(PresenceBudget);
|
||||
});
|
||||
|
||||
meter.WithTag("driver", RepublishingDriver).ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A partial recovery is still reported, and reports only what is missing: one ref comes back, the
|
||||
/// other does not, and the audit counts exactly one. An audit that reported the whole latch (or
|
||||
/// nothing at all once anything arrived) would be useless on precisely the mixed case an operator
|
||||
/// needs it for.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_partial_recovery_reports_only_the_refs_that_did_not_come_back()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedV3Deployment(db, RevA,
|
||||
rawTags: new[]
|
||||
{
|
||||
(Driver: PartialDriver, DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false),
|
||||
(Driver: PartialDriver, DriverName: "Modbus", Device: "dev1", Tag: "temp", DataType: "Double", Writable: false),
|
||||
},
|
||||
unsRefs: Array.Empty<(string, string, string, string, string?)>());
|
||||
|
||||
var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, commsRecoveryAuditDelay: AuditDelay);
|
||||
using var meter = new MeterRecorder("otopcua.driver.comms_latched_refs");
|
||||
|
||||
actor.Tell(new DriverInstanceActor.ConnectivityChanged(PartialDriver, Connected: false));
|
||||
publish.ExpectMsg<OpcUaPublishActor.AttributeStatusBatchUpdate>(PresenceBudget);
|
||||
|
||||
EventFilter.Warning(contains: "still BadNoCommunication").ExpectOne(TimeSpan.FromSeconds(10), () =>
|
||||
{
|
||||
actor.Tell(new DriverInstanceActor.ConnectivityChanged(PartialDriver, Connected: true));
|
||||
actor.Tell(new DriverInstanceActor.AttributeValueBatchPublished(PartialDriver, new[]
|
||||
{
|
||||
new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/speed", 42.0, OpcUaStatus.Good, TsA),
|
||||
}));
|
||||
publish.ExpectMsg<OpcUaPublishActor.AttributeValueBatchUpdate>(PresenceBudget);
|
||||
});
|
||||
|
||||
AwaitCondition(() => meter.WithTag("driver", PartialDriver) > 0, TimeSpan.FromSeconds(5));
|
||||
meter.SumWithTag("driver", PartialDriver).ShouldBe(1, "only the ref that never came back is reported");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A driver that owns no value refs produces no message at all — an empty batch would cost a hop
|
||||
/// and a sink call for nothing on every flap of an alarm-only or freshly-added driver.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_driver_with_no_value_refs_publishes_nothing()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedV3Deployment(db, RevA,
|
||||
rawTags: new[]
|
||||
{
|
||||
(Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false),
|
||||
},
|
||||
unsRefs: Array.Empty<(string, string, string, string, string?)>());
|
||||
|
||||
var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-unknown", Connected: false));
|
||||
|
||||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sums one counter's measurements, <b>filtered by tag</b>. A <see cref="MeterListener"/> observes
|
||||
/// every measurement in the process, so filtering on instrument name alone would fold in a parallel
|
||||
/// test's (#503) — each test here therefore uses its own driver id and asserts on that tag.
|
||||
/// </summary>
|
||||
private sealed class MeterRecorder : IDisposable
|
||||
{
|
||||
private readonly MeterListener _listener;
|
||||
private readonly List<(long Value, KeyValuePair<string, object?>[] Tags)> _measurements = new();
|
||||
private readonly object _gate = new();
|
||||
|
||||
public MeterRecorder(string instrumentName)
|
||||
{
|
||||
_listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (instrument, listener) =>
|
||||
{
|
||||
if (instrument.Meter.Name == OtOpcUaTelemetry.MeterName && instrument.Name == instrumentName)
|
||||
listener.EnableMeasurementEvents(instrument);
|
||||
},
|
||||
};
|
||||
_listener.SetMeasurementEventCallback<long>((_, value, tags, _) =>
|
||||
{
|
||||
lock (_gate) _measurements.Add((value, tags.ToArray()));
|
||||
});
|
||||
_listener.Start();
|
||||
}
|
||||
|
||||
public int WithTag(string key, string value)
|
||||
{
|
||||
lock (_gate) return _measurements.Count(m => m.Tags.Any(t => t.Key == key && Equals(t.Value, value)));
|
||||
}
|
||||
|
||||
public long SumWithTag(string key, string value)
|
||||
{
|
||||
lock (_gate)
|
||||
return _measurements.Where(m => m.Tags.Any(t => t.Key == key && Equals(t.Value, value)))
|
||||
.Sum(m => m.Value);
|
||||
}
|
||||
|
||||
public void Dispose() => _listener.Dispose();
|
||||
}
|
||||
}
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using Akka.Actor;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
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.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
using static ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness.V3ArtifactSeeder;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// <b>The 03/S15 contract test: comms loss → value nodes Bad → the driver's own re-publish clears
|
||||
/// them.</b> A real <see cref="DriverInstanceActor"/> is spawned by a real
|
||||
/// <see cref="DriverHostActor"/> around a scripted driver, so the whole seam runs: the child's
|
||||
/// <c>Reconnecting</c> entry announces comms loss, the host stamps the value nodes
|
||||
/// <c>BadNoCommunication</c>, the child reconnects and re-subscribes, and the driver's initial push is
|
||||
/// what makes the nodes Good again.
|
||||
///
|
||||
/// <para>⚠️ <b>Vacuity rule — the pushed value is IDENTICAL to the pre-outage one.</b> The host writes
|
||||
/// nothing Good on reconnect by design, so recovery depends entirely on a re-publish landing; and every
|
||||
/// dedup on that path (a driver's last-value cache, a consumer's last-published memory) keys on value.
|
||||
/// A recovery test whose driver pushes a <em>different</em> value therefore passes against a build with
|
||||
/// no force-republish anywhere — the value change alone defeats every dedup — and is not this
|
||||
/// contract's guard. The same-value push is the whole point.</para>
|
||||
///
|
||||
/// <para>The falsification control is
|
||||
/// <see cref="A_silent_resubscribe_leaves_the_nodes_Bad_and_trips_the_audit"/>: a driver whose
|
||||
/// <c>SubscribeAsync</c> pushes nothing (the pre-STAB-18 MQTT shape) leaves the nodes Bad and is caught
|
||||
/// only by the audit. It proves the test above can fail.</para>
|
||||
/// </summary>
|
||||
public sealed class DriverHostCommsLossRecoveryTests : RuntimeActorTestBase
|
||||
{
|
||||
private static readonly NodeId TestNode = NodeId.Parse("driver-commsloss-recovery");
|
||||
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('d', 64));
|
||||
|
||||
/// <summary>Audit window measured from the RECONNECT, not from the outage — the initial push lands
|
||||
/// within milliseconds of Connected, so this is generous.</summary>
|
||||
private static readonly TimeSpan AuditDelay = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>The child's reconnect retry runs on <c>DriverInstanceActor.DefaultReconnectInterval</c>
|
||||
/// (10 s) and the host exposes no seam to shorten it, so every budget here clears that.</summary>
|
||||
private static readonly TimeSpan ReconnectBudget = TimeSpan.FromSeconds(25);
|
||||
|
||||
private const string SpeedRef = "Plant/Modbus/dev1/speed";
|
||||
private const string UnsSpeedNode = "filling/line1/station1/speed";
|
||||
|
||||
[Fact]
|
||||
public void An_identical_value_pushed_on_resubscribe_recovers_the_nodes()
|
||||
{
|
||||
var driver = new ScriptedSubscribeDriver { InstanceId = "drv-recover", InitialPush = (SpeedRef, 5.0, OpcUaStatus.Good) };
|
||||
var (host, publish) = Arrange(driver, "drv-recover");
|
||||
using var meter = new MeterRecorder("otopcua.driver.comms_latched_refs");
|
||||
|
||||
// Steady state: the driver's FIRST subscribe already pushed 5 Good, and the host fanned it to
|
||||
// raw + UNS. (The push on subscribe is the behaviour under test, so it is also how the pre-outage
|
||||
// value gets there — no separate seeding call, which would leave an extra batch in the probe.)
|
||||
var first = publish.ExpectMsg<OpcUaPublishActor.AttributeValueBatchUpdate>(PresenceBudget);
|
||||
first.Items.Select(i => i.NodeId).ShouldBe(new[] { SpeedRef, UnsSpeedNode }, ignoreOrder: true);
|
||||
first.Items.ShouldAllBe(i => Equals(i.Value, 5.0));
|
||||
|
||||
// The outage: the child re-enters Reconnecting and announces comms loss to the host.
|
||||
host.Tell(new ReconnectDriver("c1", "drv-recover", "test-operator", Guid.NewGuid()));
|
||||
|
||||
var staled = publish.ExpectMsg<OpcUaPublishActor.AttributeStatusBatchUpdate>(ReconnectBudget);
|
||||
staled.Items.Select(i => i.NodeId).ShouldBe(new[] { SpeedRef, UnsSpeedNode }, ignoreOrder: true);
|
||||
staled.Items.ShouldAllBe(i => i.StatusCode == OpcUaStatus.BadNoCommunication);
|
||||
|
||||
// Recovery: the reconnect re-subscribes, and the driver pushes the SAME value it held before.
|
||||
var recovered = publish.ExpectMsg<OpcUaPublishActor.AttributeValueBatchUpdate>(ReconnectBudget);
|
||||
recovered.Items.Select(i => i.NodeId).ShouldBe(new[] { SpeedRef, UnsSpeedNode }, ignoreOrder: true);
|
||||
recovered.Items.ShouldAllBe(i => i.StatusCode == OpcUaStatus.Good);
|
||||
recovered.Items.ShouldAllBe(i => Equals(i.Value, 5.0));
|
||||
|
||||
// …and the audit finds nothing outstanding.
|
||||
EventFilter.Warning(contains: "still BadNoCommunication")
|
||||
.Expect(0, AuditDelay + TimeSpan.FromSeconds(3), () => { });
|
||||
meter.SumWithTag("driver", "drv-recover").ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>The falsification control.</b> Same scenario, but the driver's <c>SubscribeAsync</c> pushes
|
||||
/// nothing — exactly what MQTT did before 05/STAB-18, and what any push driver without an initial
|
||||
/// push does. The nodes stay Bad (the host writes no Good of its own), and the ONLY thing that says
|
||||
/// so is the recovery audit.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_silent_resubscribe_leaves_the_nodes_Bad_and_trips_the_audit()
|
||||
{
|
||||
var driver = new ScriptedSubscribeDriver { InstanceId = "drv-silent", InitialPush = null };
|
||||
var (host, publish) = Arrange(driver, "drv-silent");
|
||||
using var meter = new MeterRecorder("otopcua.driver.comms_latched_refs");
|
||||
|
||||
driver.FireDataChange(SpeedRef, 5.0, OpcUaStatus.Good);
|
||||
publish.ExpectMsg<OpcUaPublishActor.AttributeValueBatchUpdate>(PresenceBudget);
|
||||
|
||||
EventFilter.Warning(contains: "still BadNoCommunication").ExpectOne(ReconnectBudget, () =>
|
||||
{
|
||||
host.Tell(new ReconnectDriver("c1", "drv-silent", "test-operator", Guid.NewGuid()));
|
||||
publish.ExpectMsg<OpcUaPublishActor.AttributeStatusBatchUpdate>(ReconnectBudget);
|
||||
|
||||
// Nothing Good ever arrives — assert it rather than merely not looking.
|
||||
publish.ExpectNoMsg(TimeSpan.FromSeconds(1));
|
||||
});
|
||||
|
||||
// One ref (the raw tag); its UNS mirror shares it.
|
||||
AwaitCondition(() => meter.SumWithTag("driver", "drv-silent") == 1, TimeSpan.FromSeconds(5));
|
||||
driver.SubscribeCount.ShouldBeGreaterThanOrEqualTo(2,
|
||||
"the child must actually have re-subscribed — otherwise this proves nothing about the push");
|
||||
}
|
||||
|
||||
private (IActorRef Host, Akka.TestKit.TestProbe Publish) Arrange(ScriptedSubscribeDriver driver, string driverId)
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedV3Deployment(db, RevA,
|
||||
rawTags: new[]
|
||||
{
|
||||
(Driver: driverId, DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false),
|
||||
},
|
||||
unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) },
|
||||
enabled: true);
|
||||
|
||||
var (host, publish, _) = SpawnHostAndApply(
|
||||
this, db, TestNode, deploymentId, RevA,
|
||||
commsRecoveryAuditDelay: AuditDelay,
|
||||
driverFactory: new SingleDriverFactory(driver));
|
||||
|
||||
// The child is spawned + initialised by the apply; wait for its first subscribe so the scripted
|
||||
// pushes below are attributed to a live subscription.
|
||||
AwaitCondition(() => driver.SubscribeCount >= 1, PresenceBudget);
|
||||
return (host, publish);
|
||||
}
|
||||
|
||||
/// <summary>Hands the host one scripted driver, whatever type/instance it asks for.</summary>
|
||||
private sealed class SingleDriverFactory(IDriver driver) : IDriverFactory
|
||||
{
|
||||
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) => driver;
|
||||
|
||||
public IReadOnlyCollection<string> SupportedTypes { get; } = ["Modbus"];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A subscribable driver whose <see cref="SubscribeAsync"/> optionally performs an <b>initial
|
||||
/// push</b> — the poll engine's <c>forceRaise: true</c> and MQTT's post-STAB-18 behaviour, modelled
|
||||
/// at the seam the host actually observes. With <see cref="InitialPush"/> null it is the silent
|
||||
/// shape the control test needs.
|
||||
/// </summary>
|
||||
private sealed class ScriptedSubscribeDriver : IDriver, ISubscribable
|
||||
{
|
||||
private readonly StubHandle _handle = new();
|
||||
private int _subscribeCount;
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
|
||||
/// <summary>The value re-published on every (re)subscribe, or null for a silent driver.</summary>
|
||||
public (string Ref, object? Value, uint StatusCode)? InitialPush { get; init; }
|
||||
|
||||
/// <summary>The instance id the child actor adopts — <c>DriverInstanceActor</c> takes it from the
|
||||
/// DRIVER, not from the spec, and the host's ref map is keyed by the spec's id. They must agree or
|
||||
/// every published value is dropped as unmapped.</summary>
|
||||
public required string InstanceId { get; init; }
|
||||
|
||||
public int SubscribeCount => Volatile.Read(ref _subscribeCount);
|
||||
|
||||
public string DriverInstanceId => InstanceId;
|
||||
|
||||
public string DriverType => "Modbus";
|
||||
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
// Stable health: the #527 watchdog must not fire on its own here — the outage in these tests is
|
||||
// driven explicitly, and a health-driven trip would make the timing non-deterministic.
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
||||
{
|
||||
Interlocked.Increment(ref _subscribeCount);
|
||||
if (InitialPush is { } push && fullReferences.Contains(push.Ref))
|
||||
FireDataChange(push.Ref, push.Value, push.StatusCode);
|
||||
|
||||
return Task.FromResult<ISubscriptionHandle>(_handle);
|
||||
}
|
||||
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public void FireDataChange(string fullRef, object? value, uint statusCode)
|
||||
{
|
||||
var snapshot = new DataValueSnapshot(value, statusCode, DateTime.UtcNow, DateTime.UtcNow);
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(_handle, fullRef, snapshot));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Tag-filtered counter recorder — see the note in
|
||||
/// <c>DriverHostActorCommsLossValueNodesTests</c> on why the instrument name alone is not enough.</summary>
|
||||
private sealed class MeterRecorder : IDisposable
|
||||
{
|
||||
private readonly MeterListener _listener;
|
||||
private readonly List<(long Value, KeyValuePair<string, object?>[] Tags)> _measurements = new();
|
||||
private readonly object _gate = new();
|
||||
|
||||
public MeterRecorder(string instrumentName)
|
||||
{
|
||||
_listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (instrument, listener) =>
|
||||
{
|
||||
if (instrument.Meter.Name == OtOpcUaTelemetry.MeterName && instrument.Name == instrumentName)
|
||||
listener.EnableMeasurementEvents(instrument);
|
||||
},
|
||||
};
|
||||
_listener.SetMeasurementEventCallback<long>((_, value, tags, _) =>
|
||||
{
|
||||
lock (_gate) _measurements.Add((value, tags.ToArray()));
|
||||
});
|
||||
_listener.Start();
|
||||
}
|
||||
|
||||
public long SumWithTag(string key, string value)
|
||||
{
|
||||
lock (_gate)
|
||||
return _measurements.Where(m => m.Tags.Any(t => t.Key == key && Equals(t.Value, value)))
|
||||
.Sum(m => m.Value);
|
||||
}
|
||||
|
||||
public void Dispose() => _listener.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -374,4 +374,54 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
|
||||
AwaitCondition(() => driver.SubscribeCount >= 1, TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Archreview <b>03/S15 (T11)</b> — a child that is <b>stopped</b> announces comms loss on its way
|
||||
/// out, so the host latches its value nodes <c>BadNoCommunication</c>.
|
||||
/// <para>Every other route into <c>Reconnecting</c> announces it (watchdog trip, failed initial
|
||||
/// connect, Force Reconnect all run the <c>Reconnecting</c> entry). Stopping did not: an operator's
|
||||
/// Restart and a supervision respawn tore the child down silently, so its nodes kept serving their
|
||||
/// last value at Good across the gap. The fresh child's re-subscribe + initial push (03/S16) is what
|
||||
/// clears them again.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Stopping_a_connected_child_announces_comms_loss_to_its_parent()
|
||||
{
|
||||
var parent = CreateTestProbe();
|
||||
var driver = new StubDriver();
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
|
||||
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
parent.ExpectMsg<DriverInstanceActor.ConnectivityChanged>(PresenceBudget).Connected.ShouldBeTrue();
|
||||
|
||||
parent.Watch(actor);
|
||||
parent.Send(actor, PoisonPill.Instance);
|
||||
|
||||
parent.ExpectMsg<DriverInstanceActor.ConnectivityChanged>(PresenceBudget).Connected.ShouldBeFalse();
|
||||
parent.ExpectTerminated(actor, PresenceBudget);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>Control.</b> A child that never reached <c>Connected</c> announces nothing when stopped — its
|
||||
/// nodes are still <c>BadWaitingForInitialData</c>, which is a more precise statement than
|
||||
/// "communication lost", and overwriting it would claim an outage that never happened.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Stopping_a_never_connected_child_announces_nothing()
|
||||
{
|
||||
var parent = CreateTestProbe();
|
||||
var driver = new StubDriver { InitializeShouldThrow = true };
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
||||
driver, reconnectInterval: TimeSpan.FromSeconds(30)));
|
||||
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
// The failed initial connect announces Connected=false from the Reconnecting entry; consume it, so
|
||||
// what follows is only what STOPPING produced.
|
||||
parent.ExpectMsg<DriverInstanceActor.ConnectivityChanged>(PresenceBudget).Connected.ShouldBeFalse();
|
||||
|
||||
parent.Watch(actor);
|
||||
parent.Send(actor, PoisonPill.Instance);
|
||||
|
||||
parent.ExpectTerminated(actor, PresenceBudget);
|
||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,15 +140,20 @@ public static class V3ArtifactSeeder
|
||||
/// <param name="reprovisionBackoff">Optional per-driver supervision-reprovision rate limit forwarded to
|
||||
/// <c>DriverHostActor.Props</c>; null keeps the production default. Exists so a test can observe the
|
||||
/// deferral without waiting the production window.</param>
|
||||
/// <param name="commsRecoveryAuditDelay">Optional 03/S15 audit delay — how long after a reconnect the
|
||||
/// host checks for refs still latched <c>BadNoCommunication</c>. Null keeps the production default,
|
||||
/// which is far longer than any test's lifetime.</param>
|
||||
/// <returns>The host actor plus the publish and mux probes wired into it.</returns>
|
||||
public static (IActorRef Actor, TestProbe Publish, TestProbe Mux) SpawnHostAndApply(
|
||||
TestKitBase kit, IDbContextFactory<OtOpcUaConfigDbContext> db,
|
||||
NodeId localNode, DeploymentId deploymentId, RevisionHash rev,
|
||||
IDriverFactory? driverFactory = null,
|
||||
TimeSpan? reprovisionBackoff = null)
|
||||
TimeSpan? reprovisionBackoff = null,
|
||||
TimeSpan? commsRecoveryAuditDelay = null)
|
||||
{
|
||||
var publish = kit.CreateTestProbe();
|
||||
var (actor, mux, coordinator) = Spawn(kit, db, localNode, publish.Ref, driverFactory, reprovisionBackoff);
|
||||
var (actor, mux, coordinator) =
|
||||
Spawn(kit, db, localNode, publish.Ref, driverFactory, reprovisionBackoff, commsRecoveryAuditDelay);
|
||||
|
||||
Dispatch(actor, coordinator, deploymentId, rev);
|
||||
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(RuntimeActorTestBase.PresenceBudget);
|
||||
@@ -175,7 +180,8 @@ public static class V3ArtifactSeeder
|
||||
|
||||
private static (IActorRef Actor, TestProbe Mux, TestProbe Coordinator) Spawn(
|
||||
TestKitBase kit, IDbContextFactory<OtOpcUaConfigDbContext> db, NodeId localNode,
|
||||
IActorRef? opcUaPublishActor, IDriverFactory? driverFactory, TimeSpan? reprovisionBackoff)
|
||||
IActorRef? opcUaPublishActor, IDriverFactory? driverFactory, TimeSpan? reprovisionBackoff,
|
||||
TimeSpan? commsRecoveryAuditDelay = null)
|
||||
{
|
||||
var coordinator = kit.CreateTestProbe();
|
||||
var mux = kit.CreateTestProbe();
|
||||
@@ -189,7 +195,8 @@ public static class V3ArtifactSeeder
|
||||
opcUaPublishActor: opcUaPublishActor,
|
||||
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
|
||||
virtualTagHostOverride: vtHost.Ref,
|
||||
reprovisionBackoff: reprovisionBackoff));
|
||||
reprovisionBackoff: reprovisionBackoff,
|
||||
commsRecoveryAuditDelay: commsRecoveryAuditDelay));
|
||||
|
||||
return (actor, mux, coordinator);
|
||||
}
|
||||
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
using Akka.Actor;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="OpcUaPublishActor.AttributeStatusBatchUpdate"/> — the status-only route to the sink
|
||||
/// that the comms-loss fan-out uses (archreview 03/S15). It must reach
|
||||
/// <see cref="IOpcUaAddressSpaceSink.WriteStatuses"/> as exactly one call, never decompose into
|
||||
/// value writes (which would null every node's value), and survive a throwing sink.
|
||||
/// </summary>
|
||||
public sealed class OpcUaPublishActorStatusBatchTests : RuntimeActorTestBase
|
||||
{
|
||||
/// <summary>
|
||||
/// One message, one <c>WriteStatuses</c> call carrying every item verbatim — and <b>zero</b> value
|
||||
/// writes. The second half is load-bearing: a handler that fell through to <c>WriteValues</c> (or
|
||||
/// to the interface's default, which loops <c>WriteValue</c> with a null value) would satisfy "the
|
||||
/// status arrived" while erasing the last known value the annotation exists to keep.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Status_batch_reaches_sink_as_one_WriteStatuses_call()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
|
||||
|
||||
var t0 = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
var items = new[]
|
||||
{
|
||||
new StatusWrite("Plant/Modbus/dev1/Speed", OpcUaStatus.BadNoCommunication, t0, AddressSpaceRealm.Raw),
|
||||
new StatusWrite("AreaA/Line1/Eq1/Speed", OpcUaStatus.BadNoCommunication, t0, AddressSpaceRealm.Uns),
|
||||
};
|
||||
|
||||
actor.Tell(new OpcUaPublishActor.AttributeStatusBatchUpdate(items));
|
||||
|
||||
AwaitAssert(() => sink.StatusBatches.Count.ShouldBe(1), PresenceBudget);
|
||||
var batch = sink.StatusBatches[0];
|
||||
batch.Count.ShouldBe(2);
|
||||
batch[0].ShouldBe(items[0]);
|
||||
batch[1].ShouldBe(items[1]);
|
||||
|
||||
sink.ValueBatches.Count.ShouldBe(0, "a status annotation must never be routed as a value write");
|
||||
sink.SingleWrites.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A partial failure is metered honestly, exactly as the value batch is: the tally advances by the
|
||||
/// items the sink DID apply, not by zero and not by the whole batch.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Partial_failure_still_counts_the_items_that_landed()
|
||||
{
|
||||
var sink = new PartialFailureSink();
|
||||
var actor = ActorOfAsTestActorRef<OpcUaPublishActor>(OpcUaPublishActor.PropsForTests(sink: sink));
|
||||
|
||||
var t0 = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
actor.Tell(new OpcUaPublishActor.AttributeStatusBatchUpdate(new[]
|
||||
{
|
||||
new StatusWrite("Plant/Modbus/dev1/Speed", OpcUaStatus.BadNoCommunication, t0, AddressSpaceRealm.Raw),
|
||||
new StatusWrite("", OpcUaStatus.BadNoCommunication, t0, AddressSpaceRealm.Raw),
|
||||
new StatusWrite("Plant/Modbus/dev1/Temp", OpcUaStatus.BadNoCommunication, t0, AddressSpaceRealm.Raw),
|
||||
}));
|
||||
|
||||
sink.Threw.ShouldBeTrue();
|
||||
actor.UnderlyingActor.WriteCount.ShouldBe(2,
|
||||
"the two items the sink DID apply must still be counted — not 0 (the general catch) and not 3");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A throwing sink is swallowed and logged, so a node manager fault cannot restart the publish
|
||||
/// actor and drop live traffic. As in the value-batch suite, survival alone proves nothing (Akka's
|
||||
/// restart preserves the mailbox) — the load-bearing assertions are that no exception reaches
|
||||
/// supervision and that the handler logs its own Warning.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Sink_throw_is_swallowed_and_logged()
|
||||
{
|
||||
var sink = new ThrowOnWriteStatusesSink();
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
|
||||
|
||||
var t0 = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
EventFilter.Exception<InvalidOperationException>().Expect(0, () =>
|
||||
{
|
||||
EventFilter.Warning(contains: "sink.WriteStatuses threw").ExpectOne(() =>
|
||||
actor.Tell(new OpcUaPublishActor.AttributeStatusBatchUpdate(new[]
|
||||
{
|
||||
new StatusWrite("Plant/Modbus/dev1/Speed", OpcUaStatus.BadNoCommunication, t0, AddressSpaceRealm.Raw),
|
||||
})));
|
||||
|
||||
// Survival: a follow-up on the untouched per-item value path lands only if the actor is pumping.
|
||||
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
|
||||
"Plant/Modbus/dev1/Temp", 2, OpcUaStatus.Good, t0, AddressSpaceRealm.Raw));
|
||||
|
||||
AwaitAssert(() => sink.SingleWrites.Count.ShouldBe(1), PresenceBudget);
|
||||
});
|
||||
|
||||
sink.Threw.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private void AwaitAssert(Action assertion, TimeSpan duration)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + duration;
|
||||
Exception? last = null;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
try { assertion(); return; }
|
||||
catch (Exception ex) { last = ex; Thread.Sleep(25); }
|
||||
}
|
||||
if (last is not null) throw last;
|
||||
}
|
||||
|
||||
/// <summary>Records status batches, value batches and per-item value writes separately, so a handler
|
||||
/// that routed a status annotation down the value path is distinguishable from one that did not.</summary>
|
||||
private class RecordingSink : IOpcUaAddressSpaceSink
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly List<IReadOnlyList<StatusWrite>> _statusBatches = new();
|
||||
private readonly List<IReadOnlyList<ValueWrite>> _valueBatches = new();
|
||||
private readonly List<ValueWrite> _singles = new();
|
||||
|
||||
public IReadOnlyList<IReadOnlyList<StatusWrite>> StatusBatches { get { lock (_gate) return _statusBatches.ToArray(); } }
|
||||
public IReadOnlyList<IReadOnlyList<ValueWrite>> ValueBatches { get { lock (_gate) return _valueBatches.ToArray(); } }
|
||||
public IReadOnlyList<ValueWrite> SingleWrites { get { lock (_gate) return _singles.ToArray(); } }
|
||||
|
||||
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
{
|
||||
lock (_gate) _singles.Add(new ValueWrite(nodeId, value, statusCode, sourceTimestampUtc, realm));
|
||||
}
|
||||
|
||||
public void WriteValues(IReadOnlyList<ValueWrite> values)
|
||||
{
|
||||
lock (_gate) _valueBatches.Add(values.ToArray());
|
||||
}
|
||||
|
||||
// Overrides the interface's default (which loops WriteValue with a NULL value).
|
||||
public virtual void WriteStatuses(IReadOnlyList<StatusWrite> statuses)
|
||||
{
|
||||
lock (_gate) _statusBatches.Add(statuses.ToArray());
|
||||
}
|
||||
|
||||
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) { }
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
|
||||
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") { }
|
||||
}
|
||||
|
||||
/// <summary>Throws a PLAIN exception out of the status batch — drives the handler's general catch.</summary>
|
||||
private sealed class ThrowOnWriteStatusesSink : RecordingSink
|
||||
{
|
||||
private int _threw;
|
||||
|
||||
public bool Threw => Volatile.Read(ref _threw) != 0;
|
||||
|
||||
public override void WriteStatuses(IReadOnlyList<StatusWrite> statuses)
|
||||
{
|
||||
Interlocked.Exchange(ref _threw, 1);
|
||||
throw new InvalidOperationException("simulated status-write fault");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Models the real sink's PARTIAL failure: applies every item with a non-empty NodeId, then
|
||||
/// reports the aggregate as a <see cref="ValueWriteBatchException"/>.</summary>
|
||||
private sealed class PartialFailureSink : RecordingSink
|
||||
{
|
||||
private int _threw;
|
||||
|
||||
public bool Threw => Volatile.Read(ref _threw) != 0;
|
||||
|
||||
public override void WriteStatuses(IReadOnlyList<StatusWrite> statuses)
|
||||
{
|
||||
var applied = statuses.Where(s => !string.IsNullOrEmpty(s.NodeId)).ToArray();
|
||||
var failedCount = statuses.Count - applied.Length;
|
||||
if (failedCount == 0)
|
||||
{
|
||||
base.WriteStatuses(statuses);
|
||||
return;
|
||||
}
|
||||
|
||||
base.WriteStatuses(applied);
|
||||
Interlocked.Exchange(ref _threw, 1);
|
||||
throw new ValueWriteBatchException(
|
||||
firstFailedNodeId: statuses.First(s => string.IsNullOrEmpty(s.NodeId)).NodeId,
|
||||
failedCount: failedCount,
|
||||
succeededCount: applied.Length,
|
||||
firstFailure: new ArgumentException("empty NodeId"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
using Akka.Actor;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.VirtualTags;
|
||||
|
||||
/// <summary>
|
||||
/// Archreview <b>05/STAB-19</b> — the adapter that carries dependency values from the per-node
|
||||
/// dependency mux into an <see cref="IDependencyConsumer"/> (today: the Calculation driver).
|
||||
///
|
||||
/// <para><b>The defect these pin.</b> The adapter forwarded a hard-coded <c>statusCode: 0u</c> behind
|
||||
/// a comment claiming "quality is stripped at the mux boundary". That was true once; since #478/#519
|
||||
/// <see cref="VirtualTagActor.DependencyValueChanged"/> carries the source driver's full status code,
|
||||
/// sub-code included. The consequence was that a comms-lost input reached the Calculation driver as
|
||||
/// <em>arrived, Good, null</em> — indistinguishable from a real Good reading of null, which is how a
|
||||
/// calc output kept publishing Good over a dead input.</para>
|
||||
/// </summary>
|
||||
public sealed class DependencyConsumerMuxAdapterTests : RuntimeActorTestBase
|
||||
{
|
||||
private const string TagA = "Plant/Modbus/dev1/a";
|
||||
private const string TagB = "Plant/Modbus/dev1/b";
|
||||
|
||||
/// <summary>Single delivery: the status code reaches the consumer verbatim, sub-code and all.</summary>
|
||||
[Fact]
|
||||
public void A_single_dependency_value_carries_its_status_code_to_the_consumer()
|
||||
{
|
||||
var (consumer, adapter) = Spawn();
|
||||
|
||||
adapter.Tell(new VirtualTagActor.DependencyValueChanged(
|
||||
TagA, Value: null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication));
|
||||
|
||||
AwaitCondition(() => consumer.Received.Count == 1, PresenceBudget);
|
||||
consumer.Received[0].Ref.ShouldBe(TagA);
|
||||
consumer.Received[0].StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
|
||||
consumer.Received[0].StatusCode.ShouldNotBe(OpcUaStatus.Good,
|
||||
"a Bad input forwarded as Good is the laundering 05/STAB-19 is about");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batched delivery goes through the same seam, so it must carry status too — the batch path is
|
||||
/// what a real driver burst uses, and a fix applied only to the single-value path would be inert
|
||||
/// in production.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_batched_delivery_carries_each_items_status_code()
|
||||
{
|
||||
var (consumer, adapter) = Spawn();
|
||||
var ts = DateTime.UtcNow;
|
||||
|
||||
adapter.Tell(new VirtualTagActor.DependencyValueBatch(new[]
|
||||
{
|
||||
new VirtualTagActor.DependencyValueChanged(TagA, 1.0, ts, OpcUaStatus.Good),
|
||||
new VirtualTagActor.DependencyValueChanged(TagB, null, ts, OpcUaStatus.BadNoCommunication),
|
||||
}));
|
||||
|
||||
AwaitCondition(() => consumer.Received.Count == 2, PresenceBudget);
|
||||
consumer.Received[0].StatusCode.ShouldBe(OpcUaStatus.Good);
|
||||
consumer.Received[1].StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
|
||||
}
|
||||
|
||||
private (RecordingConsumer Consumer, IActorRef Adapter) Spawn()
|
||||
{
|
||||
var consumer = new RecordingConsumer();
|
||||
var mux = CreateTestProbe();
|
||||
var adapter = Sys.ActorOf(DependencyConsumerMuxAdapter.Props(consumer, mux.Ref));
|
||||
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(PresenceBudget);
|
||||
return (consumer, adapter);
|
||||
}
|
||||
|
||||
private sealed class RecordingConsumer : IDependencyConsumer
|
||||
{
|
||||
private readonly List<(string Ref, object? Value, uint StatusCode)> _received = new();
|
||||
private readonly object _gate = new();
|
||||
|
||||
public IReadOnlyList<(string Ref, object? Value, uint StatusCode)> Received
|
||||
{
|
||||
get { lock (_gate) return _received.ToArray(); }
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<string> DependencyRefs { get; } = [TagA, TagB];
|
||||
|
||||
public void OnDependencyValue(string fullReference, object? value, uint statusCode, DateTime timestampUtc)
|
||||
{
|
||||
lock (_gate) _received.Add((fullReference, value, statusCode));
|
||||
}
|
||||
}
|
||||
}
|
||||
+315
@@ -0,0 +1,315 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using Akka.Actor;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.VirtualTags;
|
||||
|
||||
/// <summary>
|
||||
/// Archreview <b>02/S14</b> — what a virtual tag does with its dependencies' <b>quality</b>.
|
||||
///
|
||||
/// <para><b>The defect.</b> The actor stored dependency values only, evaluated unconditionally, and
|
||||
/// the Roslyn read cache stamped every input Good — so a VT over a comms-lost input kept publishing
|
||||
/// fresh-Good results, and a script author could not detect it because
|
||||
/// <c>ctx.GetTag(path).StatusCode</c> read the fabricated Good. 02/S13 fixed "broken script looks
|
||||
/// Good"; this is the same lie through the other door.</para>
|
||||
///
|
||||
/// <para><b>The policy</b> is the Calculation driver's and the alarm engine's, stated once in
|
||||
/// <c>InputQuality</c>: hold on Bad and publish the worst input's sub-code; evaluate through
|
||||
/// Uncertain and stamp the output with it. The R2-03 decisions are kept, not re-litigated —
|
||||
/// transition-once, the inputs-ready gate, and force-republish on recovery.</para>
|
||||
///
|
||||
/// <para>⚠️ <b>Vacuity rule:</b> the recovery case computes the <b>identical</b> value it had before
|
||||
/// the outage. A recovery with a different value passes against a value-only dedup key and is not the
|
||||
/// guard — that dedup is exactly the "Bad forever" trap.</para>
|
||||
/// </summary>
|
||||
public sealed class VirtualTagActorInputQualityTests : RuntimeActorTestBase
|
||||
{
|
||||
private const uint Uncertain = 0x40000000u;
|
||||
|
||||
/// <summary>
|
||||
/// A Bad input holds the evaluator and publishes that input's own sub-code, once, carrying the
|
||||
/// last known value. The evaluator-call count is the load-bearing half: "the result is Bad" could
|
||||
/// also be produced by running the script against a null and failing, which is the NRE storm this
|
||||
/// replaces.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_bad_input_holds_evaluation_and_publishes_the_inputs_sub_code()
|
||||
{
|
||||
var parent = CreateTestProbe();
|
||||
var evaluator = new CountingSumEvaluator();
|
||||
var actor = parent.ChildActorOf(VirtualTagActor.Props(
|
||||
"vt-1", "a + b", evaluator: evaluator, dependencyRefs: ["a", "b"]));
|
||||
|
||||
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 10, DateTime.UtcNow));
|
||||
actor.Tell(new VirtualTagActor.DependencyValueChanged("b", 32, DateTime.UtcNow));
|
||||
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().Value.ShouldBe(10);
|
||||
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().Value.ShouldBe(42);
|
||||
var callsBefore = evaluator.Calls;
|
||||
|
||||
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication));
|
||||
|
||||
var bad = parent.ExpectMsg<VirtualTagActor.EvaluationResult>(PresenceBudget);
|
||||
bad.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
|
||||
bad.StatusCode.ShouldNotBe(OpcUaStatus.BadInternalError, "the script did not fail — its input did");
|
||||
bad.Value.ShouldBe(42, "the last known value rides out with the Bad status");
|
||||
evaluator.Calls.ShouldBe(callsBefore, "the script must NOT be run against a Bad input");
|
||||
|
||||
// Transition-once: the same Bad again changes nothing.
|
||||
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication));
|
||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
}
|
||||
|
||||
/// <summary>A different Bad sub-code republishes — the sub-code is the diagnostic.</summary>
|
||||
[Fact]
|
||||
public void A_different_bad_sub_code_is_republished()
|
||||
{
|
||||
var parent = CreateTestProbe();
|
||||
var actor = parent.ChildActorOf(VirtualTagActor.Props(
|
||||
"vt-1", "a + b", evaluator: new CountingSumEvaluator(), dependencyRefs: ["a"]));
|
||||
|
||||
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 10, DateTime.UtcNow));
|
||||
parent.ExpectMsg<VirtualTagActor.EvaluationResult>();
|
||||
|
||||
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication));
|
||||
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
|
||||
|
||||
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", null, DateTime.UtcNow, OpcUaStatus.BadWaitingForInitialData));
|
||||
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().StatusCode.ShouldBe(OpcUaStatus.BadWaitingForInitialData);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>The vacuity-sensitive assertion.</b> The input recovers holding the value it had before, so
|
||||
/// the computed result is identical to the pre-outage one — and it must still be published Good,
|
||||
/// because the node is currently Bad and only a publish can clear it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void An_identical_value_is_republished_when_recovering_from_a_bad_input()
|
||||
{
|
||||
var parent = CreateTestProbe();
|
||||
var actor = parent.ChildActorOf(VirtualTagActor.Props(
|
||||
"vt-1", "a", evaluator: new CountingSumEvaluator(), dependencyRefs: ["a"]));
|
||||
|
||||
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 10, DateTime.UtcNow));
|
||||
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().Value.ShouldBe(10);
|
||||
|
||||
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication));
|
||||
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
|
||||
|
||||
// Same value as before the outage.
|
||||
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 10, DateTime.UtcNow));
|
||||
|
||||
var recovered = parent.ExpectMsg<VirtualTagActor.EvaluationResult>(PresenceBudget);
|
||||
recovered.Value.ShouldBe(10, "the same value as before — that is the point");
|
||||
recovered.StatusCode.ShouldBe(OpcUaStatus.Good);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An Uncertain input is usable: the script runs and the output inherits the Uncertain code. The
|
||||
/// script also sees the real code — a capturing evaluator asserts it, because a VT author writing
|
||||
/// a quality-aware script is the reason the read cache had to stop fabricating Good.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void An_uncertain_input_is_evaluated_and_stamps_the_output_uncertain()
|
||||
{
|
||||
var parent = CreateTestProbe();
|
||||
var evaluator = new CountingSumEvaluator();
|
||||
var actor = parent.ChildActorOf(VirtualTagActor.Props(
|
||||
"vt-1", "a", evaluator: evaluator, dependencyRefs: ["a"]));
|
||||
|
||||
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 10, DateTime.UtcNow));
|
||||
parent.ExpectMsg<VirtualTagActor.EvaluationResult>();
|
||||
|
||||
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 11, DateTime.UtcNow, Uncertain));
|
||||
|
||||
var result = parent.ExpectMsg<VirtualTagActor.EvaluationResult>(PresenceBudget);
|
||||
result.Value.ShouldBe(11, "the script DID run — Uncertain is usable");
|
||||
result.StatusCode.ShouldBe(Uncertain);
|
||||
evaluator.LastSeenStatus.ShouldBe(Uncertain, "the script must observe the input's REAL status");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The R2-03 inputs-ready gate is preserved: with one declared dependency never pushed, a Bad
|
||||
/// arrival on the other publishes nothing — cold start is not a fault.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Nothing_is_published_while_a_declared_dependency_has_never_arrived()
|
||||
{
|
||||
var parent = CreateTestProbe();
|
||||
var actor = parent.ChildActorOf(VirtualTagActor.Props(
|
||||
"vt-1", "a + b", evaluator: new CountingSumEvaluator(), dependencyRefs: ["a", "b"]));
|
||||
|
||||
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication));
|
||||
|
||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>inputs-bad</c> outcome counter must tick only when a tag actually <b>reports</b> Bad
|
||||
/// inputs — not on every warm-up trigger, where a declared dependency simply has not arrived yet.
|
||||
/// Metering above the readiness gate turns ordinary cold start into a stream of "inputs bad"
|
||||
/// measurements, which is exactly the noise that makes an operator stop trusting the counter.
|
||||
/// <para>The absence half is paired with a positive control in the same test — the counter DOES
|
||||
/// tick once the tag reports — so "no measurements" can never pass for want of a working listener.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void The_inputs_bad_counter_does_not_tick_during_warm_up()
|
||||
{
|
||||
var parent = CreateTestProbe();
|
||||
var actor = parent.ChildActorOf(VirtualTagActor.Props(
|
||||
"vt-1", "a + b", evaluator: new CountingSumEvaluator(), dependencyRefs: ["a", "b"]));
|
||||
using var meter = new OutcomeRecorder();
|
||||
|
||||
// Warm-up: "b" has never arrived, so this is cold start, not a fault.
|
||||
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication));
|
||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
|
||||
|
||||
meter.Count("inputs-bad").ShouldBe(0, "warm-up is not an inputs-bad report");
|
||||
|
||||
// Positive control: once every dep has arrived, the tag reports — and the counter ticks.
|
||||
actor.Tell(new VirtualTagActor.DependencyValueChanged("b", 32, DateTime.UtcNow));
|
||||
parent.ExpectMsg<VirtualTagActor.EvaluationResult>(PresenceBudget)
|
||||
.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
|
||||
AwaitCondition(() => meter.Count("inputs-bad") == 1, TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A deploy's <c>ReassertValue</c> re-emits the LAST PUBLISHED status, so a VT whose input is
|
||||
/// comms-lost re-asserts <c>BadNoCommunication</c> onto the freshly-materialised node rather than
|
||||
/// Good. Re-asserting Good here would re-introduce the stale-Good lie at every deploy.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Reassert_carries_the_last_published_status()
|
||||
{
|
||||
var parent = CreateTestProbe();
|
||||
var actor = parent.ChildActorOf(VirtualTagActor.Props(
|
||||
"vt-1", "a", evaluator: new CountingSumEvaluator(), dependencyRefs: ["a"]));
|
||||
|
||||
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 10, DateTime.UtcNow));
|
||||
parent.ExpectMsg<VirtualTagActor.EvaluationResult>();
|
||||
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication));
|
||||
parent.ExpectMsg<VirtualTagActor.EvaluationResult>();
|
||||
|
||||
actor.Tell(VirtualTagActor.ReassertValue.Instance);
|
||||
|
||||
var reassert = parent.ExpectMsg<VirtualTagActor.EvaluationResult>(PresenceBudget);
|
||||
reassert.IsReassert.ShouldBeTrue();
|
||||
reassert.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
|
||||
reassert.Value.ShouldBe(10);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The batched mux delivery goes through the same gate: a burst whose worst item is Bad holds the
|
||||
/// script and publishes that sub-code. A fix applied only to the single-value path would be inert
|
||||
/// in production, where the mux batches.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_batch_containing_a_bad_item_holds_evaluation_too()
|
||||
{
|
||||
var parent = CreateTestProbe();
|
||||
var evaluator = new CountingSumEvaluator();
|
||||
var actor = parent.ChildActorOf(VirtualTagActor.Props(
|
||||
"vt-1", "a + b", evaluator: evaluator, dependencyRefs: ["a", "b"]));
|
||||
var ts = DateTime.UtcNow;
|
||||
|
||||
actor.Tell(new VirtualTagActor.DependencyValueBatch(new[]
|
||||
{
|
||||
new VirtualTagActor.DependencyValueChanged("a", 10, ts),
|
||||
new VirtualTagActor.DependencyValueChanged("b", 32, ts),
|
||||
}));
|
||||
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().Value.ShouldBe(42);
|
||||
var callsBefore = evaluator.Calls;
|
||||
|
||||
actor.Tell(new VirtualTagActor.DependencyValueBatch(new[]
|
||||
{
|
||||
new VirtualTagActor.DependencyValueChanged("a", null, ts, OpcUaStatus.BadNoCommunication),
|
||||
new VirtualTagActor.DependencyValueChanged("b", 33, ts),
|
||||
}));
|
||||
|
||||
parent.ExpectMsg<VirtualTagActor.EvaluationResult>(PresenceBudget)
|
||||
.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
|
||||
evaluator.Calls.ShouldBe(callsBefore);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts <c>otopcua.virtualtag.eval</c> measurements by their <c>outcome</c> tag.
|
||||
/// <para>A <see cref="MeterListener"/> sees every measurement in the process, and this counter is
|
||||
/// hot across the assembly — so the assertions above are scoped to the <c>inputs-bad</c> outcome,
|
||||
/// which only this suite produces (nothing else drives a Bad dependency), and the absence
|
||||
/// assertion is paired with a positive control rather than standing alone.</para>
|
||||
/// </summary>
|
||||
private sealed class OutcomeRecorder : IDisposable
|
||||
{
|
||||
private readonly MeterListener _listener;
|
||||
private readonly List<string> _outcomes = new();
|
||||
private readonly object _gate = new();
|
||||
|
||||
public OutcomeRecorder()
|
||||
{
|
||||
_listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (instrument, listener) =>
|
||||
{
|
||||
if (instrument.Meter.Name == OtOpcUaTelemetry.MeterName
|
||||
&& instrument.Name == "otopcua.virtualtag.eval")
|
||||
listener.EnableMeasurementEvents(instrument);
|
||||
},
|
||||
};
|
||||
_listener.SetMeasurementEventCallback<long>((_, _, tags, _) =>
|
||||
{
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
if (tag.Key != "outcome" || tag.Value is not string outcome) continue;
|
||||
lock (_gate) _outcomes.Add(outcome);
|
||||
}
|
||||
});
|
||||
_listener.Start();
|
||||
}
|
||||
|
||||
public int Count(string outcome)
|
||||
{
|
||||
lock (_gate) return _outcomes.Count(o => o == outcome);
|
||||
}
|
||||
|
||||
public void Dispose() => _listener.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sums the dependency values, counts its invocations, and records the status code the actor
|
||||
/// handed it for "a" — so a test can assert both that the script did NOT run and that, when it
|
||||
/// did, it saw the input's real quality.
|
||||
/// </summary>
|
||||
private sealed class CountingSumEvaluator : IVirtualTagEvaluator
|
||||
{
|
||||
private int _calls;
|
||||
|
||||
public int Calls => Volatile.Read(ref _calls);
|
||||
|
||||
public uint? LastSeenStatus { get; private set; }
|
||||
|
||||
public VirtualTagEvalResult Evaluate(
|
||||
string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
|
||||
{
|
||||
Interlocked.Increment(ref _calls);
|
||||
return VirtualTagEvalResult.Ok(Sum(dependencies.Values));
|
||||
}
|
||||
|
||||
public VirtualTagEvalResult Evaluate(
|
||||
string virtualTagId, string expression, IReadOnlyDictionary<string, DataValueSnapshot> dependencies)
|
||||
{
|
||||
Interlocked.Increment(ref _calls);
|
||||
LastSeenStatus = dependencies.TryGetValue("a", out var a) ? a.StatusCode : null;
|
||||
return VirtualTagEvalResult.Ok(Sum(dependencies.Values.Select(v => v.Value)));
|
||||
}
|
||||
|
||||
private static object Sum(IEnumerable<object?> values) =>
|
||||
values.Where(v => v is not null).Sum(v => Convert.ToInt32(v));
|
||||
}
|
||||
}
|
||||
@@ -201,6 +201,38 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
|
||||
value.ServerTimestampUtc.ShouldBe(ts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Archreview 02/S14 — a result carrying a propagated INPUT status (<c>BadNoCommunication</c>,
|
||||
/// because the VT held evaluation on a comms-lost dependency) is bridged verbatim to the publish
|
||||
/// actor <b>and historized with that status</b>, the same way 02/S13's script-failure Bad is.
|
||||
/// A bridge that special-cased status — or historized it as Good — would put the stale-Good lie
|
||||
/// back one hop downstream of the fix.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_result_carrying_a_propagated_input_status_is_bridged_and_historized_with_it()
|
||||
{
|
||||
const uint badNoCommunication = 0x80310000u;
|
||||
var publish = CreateTestProbe();
|
||||
var writer = new CapturingHistoryWriter();
|
||||
var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator(), writer));
|
||||
|
||||
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(
|
||||
new[] { PlanH("vt-1", "eq-1", "speed-rpm", historize: true) }));
|
||||
|
||||
var ts = new DateTime(2026, 8, 20, 12, 0, 0, DateTimeKind.Utc);
|
||||
host.Tell(new VirtualTagActor.EvaluationResult(
|
||||
"vt-1", 42.0, ts, CorrelationId.NewId(), badNoCommunication));
|
||||
|
||||
var update = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
|
||||
update.NodeId.ShouldBe("eq-1/speed-rpm");
|
||||
update.StatusCode.ShouldBe(badNoCommunication);
|
||||
update.Value.ShouldBe(42.0, "the last known value rides out under the Bad status");
|
||||
|
||||
AwaitAssert(() => writer.Calls.Count.ShouldBe(1));
|
||||
writer.Calls.TryPeek(out var captured).ShouldBeTrue();
|
||||
captured.Item2.StatusCode.ShouldBe(badNoCommunication);
|
||||
}
|
||||
|
||||
/// <summary>H5c: a result for a vtag whose plan has Historize=false is NOT recorded — the writer
|
||||
/// is never called — though the value is still published.</summary>
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user