From 3f8d5815b3581454da7f2eae8e3fa8dc057723d5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 20 Aug 2026 10:30:14 -0400 Subject: [PATCH] fix(runtime): mux adapter forwards the dependency's real status code (05/STAB-19) --- .../plans/R3-02-stale-good-family-plan.md | 10 ++ ...R3-02-stale-good-family-plan.md.tasks.json | 2 +- .../DependencyConsumerMuxAdapter.cs | 17 +++- .../DependencyConsumerMuxAdapterTests.cs | 91 +++++++++++++++++++ 4 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/DependencyConsumerMuxAdapterTests.cs diff --git a/archreview/plans/R3-02-stale-good-family-plan.md b/archreview/plans/R3-02-stale-good-family-plan.md index 0c497741..2926347e 100644 --- a/archreview/plans/R3-02-stale-good-family-plan.md +++ b/archreview/plans/R3-02-stale-good-family-plan.md @@ -731,3 +731,13 @@ test could only pass once the production change landed. - The identical-value rule is honoured: the driver pushes **5.0 Good** before and after the outage, so the recovery assertion cannot be satisfied by a value change. The control (`InitialPush = null`) asserts `ExpectNoMsg` — no Good ever arrives — and that the audit fires with exactly 1 latched ref. + +### T12 — `DependencyConsumerMuxAdapter` forwards the real status code + +- RED: `--filter "…DependencyConsumerMuxAdapterTests"` ⇒ **2 failed**, both + `Shouldly.ShouldAssertException : consumer.Received[…].StatusCode should be 2150694912u` (0x80310000) + — the consumer received `0` (Good) for a `BadNoCommunication` input, on both the single and the batch + path. That is the finding, reproduced at the seam. +- GREEN: 11 passed (`DependencyConsumerMuxAdapterTests` + `DependencyMuxActorTests`). +- The false "quality is stripped at the mux boundary" comment is deleted and replaced with why it was + false (theme 6: the contract moved at #478/#519 and the comment outlived the fact). diff --git a/archreview/plans/R3-02-stale-good-family-plan.md.tasks.json b/archreview/plans/R3-02-stale-good-family-plan.md.tasks.json index ab034166..ac23153c 100644 --- a/archreview/plans/R3-02-stale-good-family-plan.md.tasks.json +++ b/archreview/plans/R3-02-stale-good-family-plan.md.tasks.json @@ -87,7 +87,7 @@ { "id": "T12", "subject": "DependencyConsumerMuxAdapter forwards the real StatusCode; delete the false 'quality is stripped' comment (05/STAB-19)", - "status": "pending", + "status": "done", "blockedBy": [] }, { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyConsumerMuxAdapter.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyConsumerMuxAdapter.cs index a33bc254..7a41c587 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyConsumerMuxAdapter.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyConsumerMuxAdapter.cs @@ -66,10 +66,23 @@ public sealed class DependencyConsumerMuxAdapter : ReceiveActor _log.Debug("DependencyConsumerMuxAdapter: registered {Count} dependency ref(s)", refs.Count); } + /// + /// Forwards one dependency value to the consumer, status code included (archreview + /// 05/STAB-19). + /// + /// + /// This used to pass a hard-coded 0u behind a comment saying quality was stripped at the + /// mux boundary. That was true when the adapter was written and stopped being true at #478/#519 — + /// has carried the source driver's full status + /// code, sub-code and all, ever since. The stale comment outliving the fact is what kept a + /// comms-lost input arriving at the Calculation driver as arrived, Good, null: + /// indistinguishable from a genuine Good null, so the warm-up gate was satisfied by a value that + /// never arrived and the calc output stayed Good over a dead source. + /// + /// The dependency value, as the mux delivered it. private void OnDependencyValueChanged(VirtualTagActor.DependencyValueChanged msg) { - // Quality is stripped at the mux boundary (value + timestamp only), so forward Good (0). - _consumer.OnDependencyValue(msg.TagId, msg.Value, statusCode: 0u, msg.TimestampUtc); + _consumer.OnDependencyValue(msg.TagId, msg.Value, msg.StatusCode, msg.TimestampUtc); } /// The mux's batched delivery: one message carrying this adapter's whole matched slice of a diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/DependencyConsumerMuxAdapterTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/DependencyConsumerMuxAdapterTests.cs new file mode 100644 index 00000000..33e4bb9a --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/DependencyConsumerMuxAdapterTests.cs @@ -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; + +/// +/// Archreview 05/STAB-19 — the adapter that carries dependency values from the per-node +/// dependency mux into an (today: the Calculation driver). +/// +/// The defect these pin. The adapter forwarded a hard-coded statusCode: 0u behind +/// a comment claiming "quality is stripped at the mux boundary". That was true once; since #478/#519 +/// carries the source driver's full status code, +/// sub-code included. The consequence was that a comms-lost input reached the Calculation driver as +/// arrived, Good, null — indistinguishable from a real Good reading of null, which is how a +/// calc output kept publishing Good over a dead input. +/// +public sealed class DependencyConsumerMuxAdapterTests : RuntimeActorTestBase +{ + private const string TagA = "Plant/Modbus/dev1/a"; + private const string TagB = "Plant/Modbus/dev1/b"; + + /// Single delivery: the status code reaches the consumer verbatim, sub-code and all. + [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"); + } + + /// + /// 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. + /// + [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(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 DependencyRefs { get; } = [TagA, TagB]; + + public void OnDependencyValue(string fullReference, object? value, uint statusCode, DateTime timestampUtc) + { + lock (_gate) _received.Add((fullReference, value, statusCode)); + } + } +}