fix(v3-batch4): Equipment VirtualTag runtime dependency/publish resolution (live-gate)

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
This commit is contained in:
Joseph Doherty
2026-07-16 14:09:43 -04:00
parent 1badc10445
commit b95efb0b28
3 changed files with 104 additions and 0 deletions
@@ -31,6 +31,13 @@ public sealed class VirtualTagActor : ReceiveActor
public sealed record DependencyValueChanged(string TagId, object? Value, DateTime TimestampUtc);
/// <summary>Re-assert the last emitted value/quality to the parent bridge, bypassing the value dedup.
/// Sent by <see cref="VirtualTagHostActor"/> on every apply (deploy) to a surviving child: a deploy
/// re-materialises the child's published UNS node to <c>BadWaitingForInitialData</c>, 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.</summary>
public sealed class ReassertValue { public static readonly ReassertValue Instance = new(); private ReassertValue() { } }
/// <summary>Result emitted to the parent (the host bridge). <paramref name="Quality"/> carries the
/// OPC UA quality the node should read: <see cref="OpcUaQuality.Good"/> for a fresh computed value,
/// <see cref="OpcUaQuality.Bad"/> when the script failed/timed out (02/S13) — in which case
@@ -105,6 +112,7 @@ public sealed class VirtualTagActor : ReceiveActor
_mux = mux;
Receive<DependencyValueChanged>(OnDependencyChanged);
Receive<ReassertValue>(_ => OnReassertValue());
}
/// <inheritdoc />
@@ -122,6 +130,21 @@ public sealed class VirtualTagActor : ReceiveActor
_mux?.Tell(new DependencyMuxActor.UnregisterInterest(Self));
}
/// <summary>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 <c>BadWaitingForInitialData</c> 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: <c>DriverHostActor</c> enqueues the address-space <c>RebuildAddressSpace</c> (which
/// re-materialises the node) to the single-threaded publish actor BEFORE the <c>ApplyVirtualTags</c> that
/// triggers this re-assert, so the re-publish lands on the freshly-materialised node.</summary>
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;
@@ -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<string>(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);
}
@@ -476,6 +476,69 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
captured.Value.StatusCode.ShouldBe(0x80020000u); // BadInternalError — dormant-engine parity
}
/// <summary>
/// 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.
/// </summary>
[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<DependencyMuxActor.RegisterInterest>();
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<OpcUaPublishActor.AttributeValueUpdate>();
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<OpcUaPublishActor.AttributeValueUpdate>();
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));
}
/// <summary>Evaluator that always returns <c>Ok(value)</c> — 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.</summary>
private sealed class ConstOkEvaluator : IVirtualTagEvaluator
{
private readonly object _value;
/// <summary>Creates an evaluator that always returns <paramref name="value"/>.</summary>
/// <param name="value">The constant value to return.</param>
public ConstOkEvaluator(object value) => _value = value;
/// <summary>Returns <c>Ok(value)</c> regardless of inputs.</summary>
/// <param name="id">The tag identifier.</param>
/// <param name="expr">The expression string.</param>
/// <param name="deps">The dependency values.</param>
/// <returns>A successful result carrying the fixed value.</returns>
public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary<string, object?> deps)
=> VirtualTagEvalResult.Ok(_value);
}
/// <summary>Evaluator that returns <c>Ok(42.0)</c> on its first call and <c>Failure</c> thereafter —
/// the S13 "was working, now broken" shape. Interlocked counter so it is safe across the actor's
/// evaluation thread.</summary>