From b95efb0b288cd41fd6d641427202035e6c2017f0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 14:09:43 -0400 Subject: [PATCH] fix(v3-batch4): Equipment VirtualTag runtime dependency/publish resolution (live-gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Equipment VirtualTags published Bad at runtime whenever their dependency value was static. Root cause: a deploy re-materialises each VirtualTag's UNS node to BadWaitingForInitialData (OpcUaPublishActor.HandleRebuild), but a surviving unchanged-plan VirtualTagActor keeps its value-dedup state, so its unchanged recompute is suppressed (VirtualTagActor.OnDependencyChanged value dedup) and the freshly-reset node stays Bad forever. Only masked when the dependency value changes every poll. Fix: VirtualTagHostActor tells each surviving (not just-spawned) child to ReassertValue on every apply; the child re-emits its last value/quality, bypassing dedup. Ordering is safe — DriverHostActor enqueues the RebuildAddressSpace (materialise) to the single-threaded publish actor before the ApplyVirtualTags that triggers the re-assert, so the re-publish lands on the freshly-materialised node. Regression test: VirtualTagHostActorTests .Unchanged_redeploy_reasserts_last_value_so_a_reset_uns_node_recovers (fails before, passes after). Live-verified on docker-dev: GateVt reads Good via both the absolute and the {{equip}}/MainPressure script forms. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../VirtualTags/VirtualTagActor.cs | 23 +++++++ .../VirtualTags/VirtualTagHostActor.cs | 18 ++++++ .../VirtualTags/VirtualTagHostActorTests.cs | 63 +++++++++++++++++++ 3 files changed, 104 insertions(+) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs index c5641b81..6e29f09e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs @@ -31,6 +31,13 @@ public sealed class VirtualTagActor : ReceiveActor public sealed record DependencyValueChanged(string TagId, object? Value, DateTime TimestampUtc); + /// Re-assert the last emitted value/quality to the parent bridge, bypassing the value dedup. + /// Sent by on every apply (deploy) to a surviving child: a deploy + /// re-materialises the child's published UNS node to BadWaitingForInitialData, but an + /// unchanged-plan child keeps its dedup state, so a static-dependency recompute is suppressed and the + /// node would stay Bad forever. Re-asserting the last state recovers it. Singleton — carries no data. + public sealed class ReassertValue { public static readonly ReassertValue Instance = new(); private ReassertValue() { } } + /// Result emitted to the parent (the host bridge). carries the /// OPC UA quality the node should read: for a fresh computed value, /// when the script failed/timed out (02/S13) — in which case @@ -105,6 +112,7 @@ public sealed class VirtualTagActor : ReceiveActor _mux = mux; Receive(OnDependencyChanged); + Receive(_ => OnReassertValue()); } /// @@ -122,6 +130,21 @@ public sealed class VirtualTagActor : ReceiveActor _mux?.Tell(new DependencyMuxActor.UnregisterInterest(Self)); } + /// Re-emit the last emitted value/quality to the parent bridge, bypassing the value dedup, so a + /// deploy that re-materialised our published UNS node to BadWaitingForInitialData recovers even when + /// the recomputed value is unchanged (permanent Bad otherwise, with a static dependency). No-op until the + /// child has emitted at least once — the first dependency arrival publishes the initial value normally. + /// Ordering is safe: DriverHostActor enqueues the address-space RebuildAddressSpace (which + /// re-materialises the node) to the single-threaded publish actor BEFORE the ApplyVirtualTags that + /// triggers this re-assert, so the re-publish lands on the freshly-materialised node. + private void OnReassertValue() + { + if (!_hasLastValue && !_lastPublishedBad) return; + var quality = _lastPublishedBad ? OpcUaQuality.Bad : OpcUaQuality.Good; + Context.Parent.Tell(new EvaluationResult( + _virtualTagId, _lastValue, DateTime.UtcNow, CorrelationId.NewId(), quality)); + } + private void OnDependencyChanged(DependencyValueChanged msg) { _dependencies[msg.TagId] = msg.Value; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs index 4229d68d..7326b74d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs @@ -150,6 +150,9 @@ public sealed class VirtualTagHostActor : ReceiveActor // recreated here from the new plan, adopting the edited Expression/DependencyRefs. Children // whose plan was unchanged still have a live entry and are skipped, keeping their mux // subscriptions and last-value dedup state. + // Track the just-spawned vtags so the re-assert pass below skips them: a fresh child has no last + // value to re-assert and publishes its initial value on the first dependency arrival anyway. + var newlySpawned = new HashSet(StringComparer.Ordinal); foreach (var p in msg.Plans) { if (_children.ContainsKey(p.VirtualTagId)) continue; @@ -166,6 +169,7 @@ public sealed class VirtualTagHostActor : ReceiveActor mux: _mux)); Context.Watch(child); _children[p.VirtualTagId] = child; + newlySpawned.Add(p.VirtualTagId); _log.Debug("VirtualTagHost: spawned child for vtag {VirtualTagId}", p.VirtualTagId); } @@ -177,6 +181,20 @@ public sealed class VirtualTagHostActor : ReceiveActor _planByVtag[p.VirtualTagId] = p; } + // Re-assert last values on surviving (not just-spawned) children. Every apply coincides with a + // deploy, and DriverHostActor enqueues the address-space RebuildAddressSpace — which re-materialises + // each VirtualTag's UNS node to BadWaitingForInitialData — to the single-threaded publish actor + // BEFORE this ApplyVirtualTags. A surviving child keeps its value-dedup state, so an unchanged + // recompute is suppressed and its freshly-reset node would stay Bad forever (permanent with a static + // dependency). Telling each survivor to ReassertValue re-publishes its last state through the same + // publish actor AFTER the materialise, so the node recovers. Fresh children are skipped (nothing to + // re-assert; their first dependency arrival publishes normally). + foreach (var (vtagId, child) in _children) + { + if (newlySpawned.Contains(vtagId)) continue; + child.Tell(VirtualTagActor.ReassertValue.Instance); + } + _log.Debug("VirtualTagHost: applied (desired={Desired}, children={Children})", desired.Count, _children.Count); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs index e71a290a..cf86cf86 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs @@ -476,6 +476,69 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase captured.Value.StatusCode.ShouldBe(0x80020000u); // BadInternalError — dormant-engine parity } + /// + /// v3 Batch 4 live-gate regression (RED on the pre-fix tree): after a deploy re-materialises a + /// VirtualTag's UNS node to BadWaitingForInitialData, a surviving (unchanged-plan) child must + /// re-assert its last value so the node recovers — otherwise a static-dependency VirtualTag stays Bad + /// forever because its unchanged recompute is dedup-suppressed. Drives the REAL host + real child: + /// apply a plan, capture the spawned child via the mux probe, feed one dependency change to publish a + /// Good value (which the child then dedups), then re-apply the SAME plan (the redeploy). The fix makes + /// the survivor re-publish its last value; on the unfixed tree the second ExpectMsg times out. + /// + [Fact] + public void Unchanged_redeploy_reasserts_last_value_so_a_reset_uns_node_recovers() + { + var publish = CreateTestProbe(); + var mux = CreateTestProbe(); + var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux.Ref, new ConstOkEvaluator(7.0))); + + var plan = new[] { Plan("vt-1", "eq-1", "speed-rpm") }; + host.Tell(new VirtualTagHostActor.ApplyVirtualTags(plan)); + + // Capture the spawned child from the RegisterInterest sender so we can drive a dependency change + // through the real evaluate → parent-Tell → bridge path. + mux.ExpectMsg(); + var child = mux.LastSender; + + // First dependency change → Ok(7) → Good publish; the child now holds 7 as its dedup'd last value. + child.Tell(new VirtualTagActor.DependencyValueChanged("a", 1, DateTime.UtcNow)); + var first = publish.ExpectMsg(); + first.Value.ShouldBe(7.0); + first.Quality.ShouldBe(OpcUaQuality.Good); + + // The redeploy re-materialises the node to BadWaitingForInitialData and re-applies the UNCHANGED + // plan — the child survives with its dedup state. The fix re-asserts the last value; without it the + // node would stay Bad (an unchanged recompute of 7 is dedup-suppressed) and this ExpectMsg times out. + host.Tell(new VirtualTagHostActor.ApplyVirtualTags(plan)); + var reasserted = publish.ExpectMsg(); + reasserted.NodeId.ShouldBe("eq-1/speed-rpm"); + reasserted.Value.ShouldBe(7.0); + reasserted.Quality.ShouldBe(OpcUaQuality.Good); + + // No child churn on the unchanged redeploy — the re-assert must not respawn or re-register. + mux.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); + } + + /// Evaluator that always returns Ok(value) — a static-output VirtualTag whose recompute + /// is dedup-suppressed after the first publish, so the re-assert path is the only way its reset node + /// recovers. + private sealed class ConstOkEvaluator : IVirtualTagEvaluator + { + private readonly object _value; + + /// Creates an evaluator that always returns . + /// The constant value to return. + public ConstOkEvaluator(object value) => _value = value; + + /// Returns Ok(value) regardless of inputs. + /// The tag identifier. + /// The expression string. + /// The dependency values. + /// A successful result carrying the fixed value. + public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary deps) + => VirtualTagEvalResult.Ok(_value); + } + /// Evaluator that returns Ok(42.0) on its first call and Failure thereafter — /// the S13 "was working, now broken" shape. Interlocked counter so it is safe across the actor's /// evaluation thread.