fix(virtualtags): consume VirtualTag.TimerIntervalMs; add VirtualTagNoEffectiveTrigger gate (lmxopcua#783)
v2-ci / unit-tests (push) Waiting to run
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:
+57
@@ -0,0 +1,57 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
/// <summary>lmxopcua#783 — <c>VirtualTagNoEffectiveTrigger</c>, the VirtualTag mirror of
|
||||
/// <c>CalculationNoEffectiveTrigger</c>.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DraftValidatorVirtualTagTriggerTests
|
||||
{
|
||||
private static Script ScriptRow(string id, string source) => new()
|
||||
{
|
||||
ScriptId = id, Name = id, SourceCode = source, SourceHash = "h-" + id,
|
||||
};
|
||||
|
||||
private static VirtualTag Vt(bool changeTriggered, int? timer, string scriptId = "s1") => new()
|
||||
{
|
||||
VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "V", DataType = "Double", ScriptId = scriptId,
|
||||
ChangeTriggered = changeTriggered, TimerIntervalMs = timer,
|
||||
};
|
||||
|
||||
private static IEnumerable<ValidationError> Run(VirtualTag vt, string source) =>
|
||||
DraftValidator.Validate(new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
VirtualTags = [vt],
|
||||
Scripts = [ScriptRow("s1", source)],
|
||||
}).Where(e => e.Code == "VirtualTagNoEffectiveTrigger");
|
||||
|
||||
[Fact]
|
||||
public void Change_triggered_with_no_read_and_no_timer_is_rejected() =>
|
||||
Run(Vt(true, null), "return 1;").ShouldHaveSingleItem().Context.ShouldBe("vt-1");
|
||||
|
||||
[Fact]
|
||||
public void Not_change_triggered_and_no_timer_is_rejected() =>
|
||||
Run(Vt(false, null), "return (double)ctx.GetTag(\"a/b\").Value;").ShouldHaveSingleItem();
|
||||
|
||||
[Fact]
|
||||
public void A_timer_alone_is_an_effective_trigger() =>
|
||||
Run(Vt(true, 1000), "return 1;").ShouldBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void A_literal_read_with_change_trigger_is_an_effective_trigger() =>
|
||||
Run(Vt(true, null), "return (double)ctx.GetTag(\"a/b\").Value;").ShouldBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void An_unresolved_script_reports_no_trigger_error()
|
||||
{
|
||||
var errors = DraftValidator.Validate(new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c", VirtualTags = [Vt(true, null, "s-missing")],
|
||||
});
|
||||
errors.ShouldNotContain(e => e.Code == "VirtualTagNoEffectiveTrigger");
|
||||
}
|
||||
}
|
||||
+27
@@ -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
|
||||
|
||||
+107
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user