merge VirtualTag reassert-on-redeploy fix (live-gate: dedup+node-reset race) into v3/batch4-address-space
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user