fix(virtualtags): consume VirtualTag.TimerIntervalMs; add VirtualTagNoEffectiveTrigger gate (lmxopcua#783)
v2-ci / unit-tests (push) Waiting to run

Choice: implement the timer (option a). The field was designed and promised as a
working trigger: the entity doc says ChangeTriggered and TimerIntervalMs together
realize the trigger model, the DB check constraint requires ChangeTriggered OR a
timer >= 50, the AdminUI virtual-tag modal exposes "TimerIntervalMs (optional)" and
UnsTreeService rejects a tag with neither, and the 2026-06-08 design specified
"ChangeTriggered OR TimerIntervalMs>=50". Only the runtime never honoured it (the
standalone TimerTriggerScheduler was deleted in #603), so a timer-only tag sat at
BadWaitingForInitialData forever.

- EquipmentVirtualTagPlan carries ChangeTriggered/TimerIntervalMs (in equality, so
  an edit respawns the child); both the composer and the artifact decode fill them.
- VirtualTagActor schedules a repeating tick (PreStart, cancelled in PostStop,
  clamped to the 50 ms floor). ChangeTriggered=false makes the timer the only
  trigger. A tick during an in-flight async run (#779) is dropped, not stashed, so
  slow scripts never build a backlog of runs.
- DraftValidator VirtualTagNoEffectiveTrigger (hard error, same severity as
  CalculationNoEffectiveTrigger): no timer and no change trigger with a literal
  ctx.GetTag read.

Tests: VirtualTagActorTimerTests, DraftValidatorVirtualTagTriggerTests, artifact
decode test. Mutation-checked: no scheduling, stashing ticks, no cancel in
PostStop, and removing the validator call each fail a test.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 21:27:59 -05:00
parent 7891b3cb54
commit d9b95dcf56
9 changed files with 283 additions and 10 deletions
@@ -136,6 +136,33 @@ public sealed class DeploymentArtifactVirtualTagHistorizeParityTests
decoded.EquipmentVirtualTags.ShouldHaveSingleItem().Historize.ShouldBeFalse();
}
/// <summary>lmxopcua#783 — the triggers survive the artifact decode (they used to be dropped, so the runtime
/// never knew a timer existed); absent fields keep the entity defaults.</summary>
[Fact]
public void ParseComposition_carries_timer_and_change_trigger()
{
var blob = BlobOf(new
{
Namespaces = new[] { new { NamespaceId = "ns-eq", Kind = 0 } },
Scripts = new[] { new { ScriptId = "s-1", SourceCode = "return 1;" } },
VirtualTags = new object[]
{
new { VirtualTagId = "vt-timer", EquipmentId = "eq-1", Name = "T", DataType = "Int32", ScriptId = "s-1",
ChangeTriggered = false, TimerIntervalMs = 1000 },
new { VirtualTagId = "vt-default", EquipmentId = "eq-1", Name = "D", DataType = "Int32", ScriptId = "s-1" },
},
});
var decoded = DeploymentArtifact.ParseComposition(blob);
var t = decoded.EquipmentVirtualTags.Single(p => p.VirtualTagId == "vt-timer");
t.TimerIntervalMs.ShouldBe(1000);
t.ChangeTriggered.ShouldBeFalse();
var d = decoded.EquipmentVirtualTags.Single(p => p.VirtualTagId == "vt-default");
d.TimerIntervalMs.ShouldBeNull();
d.ChangeTriggered.ShouldBeTrue();
}
// The Pascal-case snapshot a VirtualTag EF entity serialises to (matches ConfigComposer's
// DefaultIgnoreCondition.Never whole-entity serialisation — includes Historize).
private static object ToSnapshot(VirtualTag v) => new
@@ -0,0 +1,107 @@
using Akka.Actor;
using Akka.Event;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
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;
/// <summary>lmxopcua#783 — <c>VirtualTag.TimerIntervalMs</c> drives evaluation: a timer-only tag (no
/// dependency) evaluates on its interval, ticks coalesce during a slow run, and the timer dies with the actor.</summary>
public sealed class VirtualTagActorTimerTests : RuntimeActorTestBase
{
[Fact]
public void A_timer_only_tag_with_no_dependency_evaluates_on_its_interval()
{
var parent = CreateTestProbe();
var evaluator = new CountingEvaluator();
parent.ChildActorOf(VirtualTagActor.Props("vt-1", "expr", evaluator: evaluator, timerIntervalMs: 50));
var first = parent.ExpectMsg<VirtualTagActor.EvaluationResult>(TimeSpan.FromSeconds(5));
var second = parent.ExpectMsg<VirtualTagActor.EvaluationResult>(TimeSpan.FromSeconds(5));
((int)second.Value!).ShouldBeGreaterThan((int)first.Value!);
}
[Fact]
public void Without_a_timer_a_dependency_free_tag_never_evaluates()
{
var parent = CreateTestProbe();
var evaluator = new CountingEvaluator();
parent.ChildActorOf(VirtualTagActor.Props("vt-1", "expr", evaluator: evaluator));
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
evaluator.Calls.ShouldBe(0);
}
[Fact]
public void ChangeTriggered_false_ignores_dependency_changes_and_evaluates_only_on_the_timer()
{
var parent = CreateTestProbe();
var evaluator = new CountingEvaluator();
var actor = parent.ChildActorOf(VirtualTagActor.Props(
"vt-1", "expr", evaluator: evaluator, changeTriggered: false, timerIntervalMs: 2000));
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 1, DateTime.UtcNow));
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(150));
evaluator.Calls.ShouldBe(0);
parent.ExpectMsg<VirtualTagActor.EvaluationResult>(TimeSpan.FromSeconds(10));
}
[Fact]
public void Ticks_during_a_slow_run_coalesce_instead_of_piling_up()
{
var parent = CreateTestProbe();
var evaluator = new CountingEvaluator(gateFirst: true);
parent.ChildActorOf(VirtualTagActor.Props("vt-1", "expr", evaluator: evaluator, timerIntervalMs: 50));
AwaitCondition(() => evaluator.Calls == 1, PresenceBudget);
Thread.Sleep(600); // ~12 ticks fire while the first run is in flight
evaluator.Calls.ShouldBe(1);
evaluator.Release();
parent.ExpectMsg<VirtualTagActor.EvaluationResult>(TimeSpan.FromSeconds(5));
// A backlog replay would run ~11 more evaluations immediately; coalesced, at most one tick lands here.
Thread.Sleep(30);
evaluator.Calls.ShouldBeLessThanOrEqualTo(3);
}
[Fact]
public void The_timer_stops_when_the_actor_stops()
{
var parent = CreateTestProbe();
var evaluator = new CountingEvaluator();
var actor = parent.ChildActorOf(VirtualTagActor.Props("vt-1", "expr", evaluator: evaluator, timerIntervalMs: 50));
parent.ExpectMsg<VirtualTagActor.EvaluationResult>(TimeSpan.FromSeconds(5));
Watch(actor);
Sys.Stop(actor);
ExpectTerminated(actor);
Thread.Sleep(100); // let any already-enqueued tick drain
// A scheduler that was never cancelled keeps Telling the dead actor: those land as dead letters.
Sys.EventStream.Subscribe(TestActor, typeof(DeadLetter));
ExpectNoMsg(TimeSpan.FromMilliseconds(200)); // ~12 intervals at TIMEFACTOR 3
}
private sealed class CountingEvaluator(bool gateFirst = false) : IVirtualTagEvaluator
{
private readonly TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously);
private int _calls;
public int Calls => Volatile.Read(ref _calls);
public void Release() => _gate.TrySetResult();
public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary<string, object?> deps)
=> VirtualTagEvalResult.Ok(Interlocked.Increment(ref _calls));
public Task<VirtualTagEvalResult> EvaluateAsync(
string id, string expr, IReadOnlyDictionary<string, DataValueSnapshot> deps)
{
var n = Interlocked.Increment(ref _calls);
return gateFirst && n == 1
? _gate.Task.ContinueWith(_ => VirtualTagEvalResult.Ok(n), TaskScheduler.Default)
: Task.FromResult(VirtualTagEvalResult.Ok(n));
}
}
}