44b99a9939
Accumulated fast-follows from the Task 1-11 reviews. No behaviour change on the value path. - Strengthen OpcUaPublishActorBatchTests.Sink_throw_is_swallowed_and_logged. The survival assertion alone did NOT prove the catch exists: Akka's default supervision RESTARTS a throwing actor and the restart preserves the mailbox, so the follow-up message landed either way and the test passed against a build with no catch at all. It now asserts via EventFilter that the exception NEVER reaches supervision (Expect(0) on the InvalidOperationException) and that the handler's own Warning IS logged, keeping the survival assertion. Everything that must have happened goes inside the Expect(0) action — it counts matches at the end of the action, so an assertion left outside would race the actor and pass vacuously. Verified falsifiable: deleting the catch turns the test red. - MapKey(NodeId): the non-string identifier branch collapsed to a string.Empty key silently. Such a key cannot match anything the manager stored, so the caller sees an unexplained miss rather than an error. It now logs a Utils.LogWarning naming the seam. Every current call site is provably string-only, so this should never fire. - The two WireAlarmNotifiers Utils.LogInfo sites interpolated the RealmKey struct, rendering a raw struct dump. Interpolate .Id and .Realm as separate placeholders so log-grep patterns stay consistent with the file's other sites. - Stale comments: the _eventNotifierSources key is a RealmKey now, not a namespace-qualified NodeId string; and three comments still named DriverHostActor.ForwardToMux, which Task 8 replaced with ForwardBatchToMux.
226 lines
12 KiB
C#
226 lines
12 KiB
C#
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>
|
|
/// Task 7 of the value-pipeline batching work: <see cref="OpcUaPublishActor.AttributeValueBatchUpdate"/>
|
|
/// must reach the sink as exactly ONE <see cref="IOpcUaAddressSpaceSink.WriteValues"/> call — that single
|
|
/// call is the whole point (the SDK-backed sink takes its lock once per batch instead of once per value),
|
|
/// so a handler that looped <c>WriteValue</c> would pass a "the values arrived" assertion while delivering
|
|
/// none of the win. Hence the fake counts per-item <c>WriteValue</c> calls separately and the assertions
|
|
/// require that count to be zero.
|
|
/// <para>The per-item <see cref="OpcUaPublishActor.AttributeValueUpdate"/> receive stays — VirtualTagHostActor
|
|
/// and the alarm paths still send it — so it is pinned here too.</para>
|
|
/// </summary>
|
|
public sealed class OpcUaPublishActorBatchTests : RuntimeActorTestBase
|
|
{
|
|
/// <summary>A 3-item batch lands as one WriteValues call carrying all three items verbatim (field-exact,
|
|
/// realm included), and never decomposes into per-item WriteValue calls.</summary>
|
|
[Fact]
|
|
public void Batch_message_reaches_sink_as_one_WriteValues_call()
|
|
{
|
|
var sink = new RecordingSink();
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
|
|
|
|
var t0 = new DateTime(2026, 8, 15, 10, 0, 0, DateTimeKind.Utc);
|
|
var items = new[]
|
|
{
|
|
new ValueWrite("Plant/Modbus/dev1/Speed", 42, OpcUaStatus.Good, t0, AddressSpaceRealm.Raw),
|
|
new ValueWrite("AreaA/Line1/Eq1/Speed", 42, OpcUaStatus.Good, t0, AddressSpaceRealm.Uns),
|
|
new ValueWrite("Plant/Modbus/dev1/Temp", 7.5d, OpcUaStatus.BadNoCommunication,
|
|
t0.AddSeconds(1), AddressSpaceRealm.Raw),
|
|
};
|
|
|
|
actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate(items));
|
|
|
|
AwaitAssert(() => sink.Batches.Count.ShouldBe(1), PresenceBudget);
|
|
|
|
var batch = sink.Batches[0];
|
|
batch.Count.ShouldBe(3);
|
|
batch[0].ShouldBe(items[0]);
|
|
batch[1].ShouldBe(items[1]);
|
|
batch[2].ShouldBe(items[2]);
|
|
|
|
// The load-bearing half: one batched call, NOT three per-item calls.
|
|
sink.SingleWrites.Count.ShouldBe(0);
|
|
}
|
|
|
|
/// <summary>A sink that throws out of WriteValues is swallowed and logged — the actor survives and keeps
|
|
/// processing, rather than restarting and dropping traffic.
|
|
/// <para>The survival assertion alone does NOT prove the catch exists: Akka's default supervision
|
|
/// RESTARTS a throwing actor and the restart preserves the mailbox, so a follow-up message lands either
|
|
/// way. The load-bearing assertions are therefore the two event filters — the exception must never reach
|
|
/// supervision (no Error carrying it is ever logged), and the handler's own Warning must be logged so the
|
|
/// fault is swallowed but not silent. Deleting the catch turns BOTH red.</para></summary>
|
|
[Fact]
|
|
public void Sink_throw_is_swallowed_and_logged()
|
|
{
|
|
var sink = new ThrowOnWriteValuesSink();
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
|
|
|
|
var t0 = new DateTime(2026, 8, 15, 10, 0, 0, DateTimeKind.Utc);
|
|
|
|
// Expect(0, …) counts matches at the END of the action, so everything that must have happened by
|
|
// then goes INSIDE it — otherwise the absence assertion races the actor and passes vacuously.
|
|
EventFilter.Exception<InvalidOperationException>().Expect(0, () =>
|
|
{
|
|
// The handler caught it AND said so: its own Warning, not supervision's Error.
|
|
EventFilter.Warning(contains: "sink.WriteValues threw").ExpectOne(() =>
|
|
actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate(new[]
|
|
{
|
|
new ValueWrite("Plant/Modbus/dev1/Speed", 1, OpcUaStatus.Good, t0, AddressSpaceRealm.Raw),
|
|
})));
|
|
|
|
// Survival: a follow-up on the untouched per-item path lands only if the actor is still 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();
|
|
}
|
|
|
|
/// <summary>A PARTIAL failure is metered honestly: a sink that applies 2 of 3 items and reports the
|
|
/// aggregate as a <see cref="ValueWriteBatchException"/> must advance the write tally by the SUCCEEDED
|
|
/// count, not by zero (the general catch's behaviour) and not by the whole batch. The invariant the
|
|
/// batching work must not break is that the tally counts per ITEM — so a batch that half-failed reports
|
|
/// exactly the half that landed.</summary>
|
|
[Fact]
|
|
public void Partial_failure_still_counts_the_items_that_landed()
|
|
{
|
|
var sink = new PartialFailureSink();
|
|
// TestActorRef so the actor's own WriteCount tally is readable — the counter the handler advances is
|
|
// the thing under test, and nothing else observes it.
|
|
var actor = ActorOfAsTestActorRef<OpcUaPublishActor>(OpcUaPublishActor.PropsForTests(sink: sink));
|
|
|
|
var t0 = new DateTime(2026, 8, 15, 10, 0, 0, DateTimeKind.Utc);
|
|
actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate(new[]
|
|
{
|
|
new ValueWrite("Plant/Modbus/dev1/Speed", 1, OpcUaStatus.Good, t0, AddressSpaceRealm.Raw),
|
|
new ValueWrite("", 2, OpcUaStatus.Good, t0, AddressSpaceRealm.Raw),
|
|
new ValueWrite("Plant/Modbus/dev1/Temp", 3, OpcUaStatus.Good, 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");
|
|
|
|
// The actor survived the partial failure and keeps pumping.
|
|
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
|
|
"Plant/Modbus/dev1/Other", 4, OpcUaStatus.Good, t0, AddressSpaceRealm.Raw));
|
|
sink.SingleWrites.Count.ShouldBe(1);
|
|
actor.UnderlyingActor.WriteCount.ShouldBe(3);
|
|
}
|
|
|
|
/// <summary>The pre-existing single-item AttributeValueUpdate path is untouched by the batch addition.</summary>
|
|
[Fact]
|
|
public void Single_item_message_still_works()
|
|
{
|
|
var sink = new RecordingSink();
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
|
|
|
|
var t0 = new DateTime(2026, 8, 15, 10, 0, 0, DateTimeKind.Utc);
|
|
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
|
|
"AreaA/Line1/Eq1/Speed", 99, OpcUaStatus.Good, t0, AddressSpaceRealm.Uns));
|
|
|
|
AwaitAssert(() => sink.SingleWrites.Count.ShouldBe(1), PresenceBudget);
|
|
sink.SingleWrites[0].ShouldBe(new ValueWrite("AreaA/Line1/Eq1/Speed", 99, OpcUaStatus.Good, t0, AddressSpaceRealm.Uns));
|
|
sink.Batches.Count.ShouldBe(0);
|
|
}
|
|
|
|
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 batched and per-item value writes separately, so a handler that looped WriteValue
|
|
/// instead of calling WriteValues once is distinguishable from one that batched.</summary>
|
|
private class RecordingSink : IOpcUaAddressSpaceSink
|
|
{
|
|
private readonly object _gate = new();
|
|
private readonly List<IReadOnlyList<ValueWrite>> _batches = new();
|
|
private readonly List<ValueWrite> _singles = new();
|
|
|
|
public IReadOnlyList<IReadOnlyList<ValueWrite>> Batches { get { lock (_gate) return _batches.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));
|
|
}
|
|
|
|
// Overrides the interface's default WriteValues so the batch is observed as one call, not N.
|
|
public virtual void WriteValues(IReadOnlyList<ValueWrite> values)
|
|
{
|
|
lock (_gate) _batches.Add(values.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>A sink whose batched write throws a PLAIN exception — drives the handler's GENERAL catch (an
|
|
/// unrecognised sink failure, where nothing is known about what landed, so nothing is counted).</summary>
|
|
private sealed class ThrowOnWriteValuesSink : RecordingSink
|
|
{
|
|
private int _threw;
|
|
|
|
public bool Threw => Volatile.Read(ref _threw) != 0;
|
|
|
|
public override void WriteValues(IReadOnlyList<ValueWrite> values)
|
|
{
|
|
Interlocked.Exchange(ref _threw, 1);
|
|
throw new InvalidOperationException("simulated batched-write fault");
|
|
}
|
|
}
|
|
|
|
/// <summary>A sink modelling the real SDK-backed one's PARTIAL failure: it applies every item with a
|
|
/// non-empty NodeId, then reports the aggregate as a <see cref="ValueWriteBatchException"/>. Drives the
|
|
/// handler's typed catch.</summary>
|
|
private sealed class PartialFailureSink : RecordingSink
|
|
{
|
|
private int _threw;
|
|
|
|
public bool Threw => Volatile.Read(ref _threw) != 0;
|
|
|
|
public override void WriteValues(IReadOnlyList<ValueWrite> values)
|
|
{
|
|
var applied = values.Where(v => !string.IsNullOrEmpty(v.NodeId)).ToArray();
|
|
var failedCount = values.Count - applied.Length;
|
|
if (failedCount == 0)
|
|
{
|
|
base.WriteValues(values);
|
|
return;
|
|
}
|
|
|
|
base.WriteValues(applied);
|
|
Interlocked.Exchange(ref _threw, 1);
|
|
throw new ValueWriteBatchException(
|
|
firstFailedNodeId: values.First(v => string.IsNullOrEmpty(v.NodeId)).NodeId,
|
|
failedCount: failedCount,
|
|
succeededCount: applied.Length,
|
|
firstFailure: new ArgumentException("empty NodeId"));
|
|
}
|
|
}
|
|
}
|