52708bff52
MAJOR-1: R4's exact element-type match refused ScadaBridge's CLR-list writes (its element types are only Boolean/Int32/Float/Double/String/ DateTime) to every Int16/UInt16/Byte/UInt32/Int64/UInt64 array node and a Double list to a Float array. EvaluateEquipmentWriteStructure now converts a numeric array of another numeric element type element-wise, CHECKED, into the node's element type and hands the converted array back through OnEquipmentTagWrite's ref value, so the node stores and the driver receives the node's own type. Any out-of-range, fractional-to-integer, NaN/Inf-to- integer or Float-overflowing element refuses the whole write BadTypeMismatch (nothing dispatched) - so the UInt32 -> Int32 bit reinterpretation stays closed. Non-numeric mismatches stay refused. The wrong 'no client relies on a looser rule' comment is corrected. MINOR-1: the array-value-to-scalar-node refusal broke writes back to browse-committed array tags (committed scalar, isArray never set) on pass-through drivers. It now applies only when the owning driver's scalar encoder cannot take an array (DriverArrayWriteSupport. RefusesArrayValueOnScalarTag: Modbus, S7), carried RawTagPlan. ScalarRefusesArrayValue / UnsReferenceVariable.BackingScalarRefusesArrayValue -> EnsureVariable(refuseArrayOnScalar) -> a per-node set in the node manager. OpcUaClient/Galaxy/others keep pre-#610 dispatch. Not on the classifier whitelist (driver-type edit rebuilds); a mirror whose flag changes is recreated, not updated in place. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
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, bool refuseArrayOnScalar = false) { }
|
|
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"));
|
|
}
|
|
}
|
|
}
|