From 04e6a4690c35ba199a8bbb353732be7f4e3d8383 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 15 Aug 2026 12:16:22 -0400 Subject: [PATCH] feat(runtime): OpcUaPublishActor batched value updates --- .../OpcUa/OpcUaPublishActor.cs | 24 +++ .../OpcUa/OpcUaPublishActorBatchTests.cs | 149 ++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorBatchTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index 63292de0..631feed5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -51,6 +51,11 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// hands it to the SDK verbatim. public sealed record AttributeValueUpdate(string NodeId, object? Value, uint StatusCode, DateTime TimestampUtc, AddressSpaceRealm Realm); + /// Batched form of : every fan-out write of one driver + /// production burst in one message — the sink takes its lock once for the whole batch. Items carry + /// their realm each (); status codes ride verbatim (#519). + public sealed record AttributeValueBatchUpdate(IReadOnlyList Items); + /// Carries the full Part 9 condition state for a scripted alarm to the sink. The /// snapshot is the Commons projection the Runtime host maps from the engine's /// Core AlarmConditionState + severity/message — the actor stays decoupled from @@ -286,6 +291,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers _healthTickInterval = healthTickInterval ?? TimeSpan.FromSeconds(5); Receive(HandleAttributeUpdate); + Receive(HandleAttributeBatchUpdate); Receive(HandleAlarmUpdate); Receive(HandleAlarmQualityUpdate); Receive(HandleRebuild); @@ -330,6 +336,24 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers } } + /// Mirrors for a whole burst: ONE + /// call, but the write tally and the + /// otopcua.opcua.sink.write meter still advance PER ITEM — batching is a transport change, so + /// dashboards must not drop by the batch factor. + private void HandleAttributeBatchUpdate(AttributeValueBatchUpdate msg) + { + try + { + _sink.WriteValues(msg.Items); + Interlocked.Add(ref _writes, msg.Items.Count); + OtOpcUaTelemetry.OpcUaSinkWrite.Add(msg.Items.Count, new KeyValuePair("kind", "value")); + } + catch (Exception ex) + { + _log.Warning(ex, "OpcUaPublish: sink.WriteValues threw for a {Count}-item batch", msg.Items.Count); + } + } + private void HandleAlarmUpdate(AlarmStateUpdate msg) { try diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorBatchTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorBatchTests.cs new file mode 100644 index 00000000..23a776d0 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorBatchTests.cs @@ -0,0 +1,149 @@ +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; + +/// +/// Task 7 of the value-pipeline batching work: +/// must reach the sink as exactly ONE 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 WriteValue would pass a "the values arrived" assertion while delivering +/// none of the win. Hence the fake counts per-item WriteValue calls separately and the assertions +/// require that count to be zero. +/// The per-item receive stays — VirtualTagHostActor +/// and the alarm paths still send it — so it is pinned here too. +/// +public sealed class OpcUaPublishActorBatchTests : RuntimeActorTestBase +{ + /// 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. + [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); + } + + /// A sink that throws out of WriteValues is swallowed and logged — the actor survives and keeps + /// processing (proved by a follow-up message landing), rather than restarting and dropping traffic. + [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); + actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate(new[] + { + new ValueWrite("Plant/Modbus/dev1/Speed", 1, OpcUaStatus.Good, t0, AddressSpaceRealm.Raw), + })); + + // Follow-up on the untouched per-item path: it lands only if the actor is still alive and 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(); + } + + /// The pre-existing single-item AttributeValueUpdate path is untouched by the batch addition. + [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; + } + + /// 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. + private class RecordingSink : IOpcUaAddressSpaceSink + { + private readonly object _gate = new(); + private readonly List> _batches = new(); + private readonly List _singles = new(); + + public IReadOnlyList> Batches { get { lock (_gate) return _batches.ToArray(); } } + public IReadOnlyList 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 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 notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } + public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } + } + + /// A sink whose batched write throws — drives the handler's catch. + private sealed class ThrowOnWriteValuesSink : RecordingSink + { + private int _threw; + + public bool Threw => Volatile.Read(ref _threw) != 0; + + public override void WriteValues(IReadOnlyList values) + { + Interlocked.Exchange(ref _threw, 1); + throw new InvalidOperationException("simulated batched-write fault"); + } + } +}