fix(vtags): respawn equipment virtualtag child on in-place plan change (H1b, stillpending §1)
This commit is contained in:
@@ -39,6 +39,9 @@ public sealed class VirtualTagHostActor : ReceiveActor
|
||||
private readonly Dictionary<string, IActorRef> _children = new(StringComparer.Ordinal);
|
||||
// vtagId -> folder-scoped OPC UA NodeId the materialiser placed the variable at.
|
||||
private readonly Dictionary<string, string> _nodeIdByVtag = new(StringComparer.Ordinal);
|
||||
// vtagId -> the plan its currently-spawned child was built from, so we can detect an in-place
|
||||
// change (edited Expression/DependencyRefs) and stop+respawn the child to adopt it.
|
||||
private readonly Dictionary<string, EquipmentVirtualTagPlan> _planByVtag = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Factory method to create Props for a VirtualTagHostActor.</summary>
|
||||
/// <param name="publishActor">The OPC UA publish actor that consumes
|
||||
@@ -78,6 +81,22 @@ public sealed class VirtualTagHostActor : ReceiveActor
|
||||
_children.Remove(vtagId);
|
||||
}
|
||||
|
||||
// Stop + forget children whose plan changed in place (edited Expression/DependencyRefs, or a
|
||||
// toggled flag). EquipmentVirtualTagPlan has element-wise value equality, so an identical
|
||||
// redeploy diffs equal and is left untouched. Forgetting the child here lets the spawn-new
|
||||
// loop below recreate it from the new plan — children are auto-named (no explicit name), so a
|
||||
// stop+respawn cannot hit the "actor name not unique" collision.
|
||||
foreach (var p in msg.Plans)
|
||||
{
|
||||
if (_children.ContainsKey(p.VirtualTagId)
|
||||
&& _planByVtag.TryGetValue(p.VirtualTagId, out var prev)
|
||||
&& !prev.Equals(p))
|
||||
{
|
||||
Context.Stop(_children[p.VirtualTagId]); // PostStop unregisters the old mux interest.
|
||||
_children.Remove(p.VirtualTagId);
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild the NodeId map every apply so renames (Name/FolderPath/EquipmentId changes) are
|
||||
// picked up. The map only contains currently-desired vtags, so a result for a removed vtag
|
||||
// finds no entry and is dropped.
|
||||
@@ -87,15 +106,13 @@ public sealed class VirtualTagHostActor : ReceiveActor
|
||||
_nodeIdByVtag[p.VirtualTagId] = NodeIdFor(p);
|
||||
}
|
||||
|
||||
// Spawn children for new vtagIds only — existing children keep their mux subscriptions and
|
||||
// last-value dedup state. Expression/dependency changes on an existing vtag are NOT
|
||||
// re-applied here; the loader's vtags are stable, and a future enhancement can stop+respawn
|
||||
// a child whose plan changed (the diff already identifies ChangedEquipmentVirtualTags).
|
||||
// Spawn children for vtagIds that have no live child. This covers genuinely-new vtags AND any
|
||||
// vtag whose child was just forgotten above because its plan changed in place — that child is
|
||||
// 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.
|
||||
foreach (var p in msg.Plans)
|
||||
{
|
||||
// TODO(equipment-virtualtags): when a plan's Expression/DependencyRefs change in place
|
||||
// (ChangedEquipmentVirtualTags), stop+respawn the child here; today only spawn-new/stop-removed
|
||||
// is handled (loader vtags are stable).
|
||||
if (_children.ContainsKey(p.VirtualTagId)) continue;
|
||||
|
||||
// Auto-name the child: vtagIds can contain characters illegal in actor names, so let Akka
|
||||
@@ -113,6 +130,14 @@ public sealed class VirtualTagHostActor : ReceiveActor
|
||||
_log.Debug("VirtualTagHost: spawned child for vtag {VirtualTagId}", p.VirtualTagId);
|
||||
}
|
||||
|
||||
// Refresh the plan map to exactly the desired set so the next apply can detect in-place
|
||||
// changes against what each live child was actually built from.
|
||||
_planByVtag.Clear();
|
||||
foreach (var p in msg.Plans)
|
||||
{
|
||||
_planByVtag[p.VirtualTagId] = p;
|
||||
}
|
||||
|
||||
_log.Debug("VirtualTagHost: applied (desired={Desired}, children={Children})",
|
||||
desired.Count, _children.Count);
|
||||
}
|
||||
|
||||
@@ -153,6 +153,96 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
|
||||
secondChild.ShouldNotBe(firstChild);
|
||||
}
|
||||
|
||||
/// <summary>A plan with an explicit Expression + DependencyRefs (the H1b in-place-change case).</summary>
|
||||
private static EquipmentVirtualTagPlan PlanWithRefs(
|
||||
string vtagId, string equipmentId, string name, string expression, params string[] refs) =>
|
||||
new(
|
||||
VirtualTagId: vtagId,
|
||||
EquipmentId: equipmentId,
|
||||
FolderPath: "",
|
||||
Name: name,
|
||||
DataType: "Double",
|
||||
Expression: expression,
|
||||
DependencyRefs: refs);
|
||||
|
||||
/// <summary>
|
||||
/// H1b respawn-on-change: re-applying the SAME vtagId with a changed Expression/DependencyRefs
|
||||
/// must stop the old child (PostStop ⇒ UnregisterInterest on the old refs) and spawn a fresh one
|
||||
/// (PreStart ⇒ RegisterInterest on the new refs). Proof: after the second apply the mux probe sees
|
||||
/// an UnregisterInterest and a RegisterInterest carrying the NEW ref "B".
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ApplyVirtualTags_respawns_child_when_plan_changes_in_place()
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var mux = CreateTestProbe();
|
||||
var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux.Ref, new StubEvaluator()));
|
||||
|
||||
// First apply — child self-registers interest in "A".
|
||||
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(
|
||||
new[] { PlanWithRefs("vt-1", "eq-1", "speed-rpm", "ctx.GetTag(\"A\")", "A") }));
|
||||
var reg1 = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
|
||||
reg1.TagRefs.ShouldContain("A");
|
||||
var firstChild = mux.LastSender;
|
||||
|
||||
// Re-apply the SAME vtagId with a changed Expression + DependencyRefs.
|
||||
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(
|
||||
new[] { PlanWithRefs("vt-1", "eq-1", "speed-rpm", "ctx.GetTag(\"B\")", "B") }));
|
||||
|
||||
// The old child is stopped (PostStop ⇒ UnregisterInterest) and a new one spawned
|
||||
// (PreStart ⇒ RegisterInterest on "B"). Both messages arrive at the mux probe; order between
|
||||
// the dying child's PostStop and the new child's PreStart is not guaranteed, so accept either.
|
||||
DependencyMuxActor.RegisterInterest? reg2 = null;
|
||||
var sawUnregister = false;
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var msg = mux.ReceiveOne(TimeSpan.FromSeconds(5));
|
||||
switch (msg)
|
||||
{
|
||||
case DependencyMuxActor.RegisterInterest r:
|
||||
reg2 = r;
|
||||
break;
|
||||
case DependencyMuxActor.UnregisterInterest:
|
||||
sawUnregister = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
sawUnregister.ShouldBeTrue("old child's PostStop should have unregistered its interest");
|
||||
reg2.ShouldNotBeNull();
|
||||
reg2!.TagRefs.ShouldContain("B");
|
||||
reg2.TagRefs.ShouldNotContain("A");
|
||||
|
||||
// The replacement is a different actor ref than the original (auto-named, so no collision).
|
||||
mux.LastSender.ShouldNotBe(firstChild);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// H1b no-churn: re-applying an IDENTICAL plan must NOT respawn the child — the plan's value
|
||||
/// equality diffs empty, so no second Unregister/Register pair hits the mux.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ApplyVirtualTags_does_not_respawn_child_when_plan_unchanged()
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var mux = CreateTestProbe();
|
||||
var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux.Ref, new StubEvaluator()));
|
||||
|
||||
var plan = new[] { PlanWithRefs("vt-1", "eq-1", "speed-rpm", "ctx.GetTag(\"A\")", "A") };
|
||||
|
||||
// First apply — exactly one RegisterInterest.
|
||||
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(plan));
|
||||
var reg = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
|
||||
reg.TagRefs.ShouldContain("A");
|
||||
|
||||
// Re-apply an identical plan (fresh list instances, but value-equal) — no churn expected.
|
||||
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(
|
||||
new[] { PlanWithRefs("vt-1", "eq-1", "speed-rpm", "ctx.GetTag(\"A\")", "A") }));
|
||||
|
||||
// No second Register and no Unregister: the child was left in place.
|
||||
mux.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
|
||||
}
|
||||
|
||||
/// <summary>Deterministic no-op evaluator: keeps spawned children inert so tests drive the host's
|
||||
/// OnResult path directly via synthetic EvaluationResults.</summary>
|
||||
private sealed class StubEvaluator : IVirtualTagEvaluator
|
||||
|
||||
Reference in New Issue
Block a user