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
@@ -40,6 +40,7 @@ public static class DraftValidator
ValidateUnsEffectiveLeafUniqueness(draft, errors);
ValidateEquipReferenceResolution(draft, errors);
ValidateCalculationTags(draft, errors);
ValidateVirtualTagTriggers(draft, errors);
ValidateSqlConnectionStringNotPersisted(draft, errors);
errors.AddRange(SqlTagDeployChecks.UndeclaredQueryErrors(draft));
ValidateMTConnectSingleDevice(draft, errors);
@@ -480,6 +481,32 @@ public static class DraftValidator
}
}
/// <summary>VirtualTag effective-trigger gate (lmxopcua#783), the VirtualTag mirror of
/// <c>CalculationNoEffectiveTrigger</c>: a VirtualTag evaluates on a literal <c>ctx.GetTag</c> dependency
/// changing (when <c>ChangeTriggered</c>) and/or on its <c>TimerIntervalMs</c>. Neither means it sits at
/// BadWaitingForInitialData forever while everything reports Healthy.</summary>
private static void ValidateVirtualTagTriggers(DraftSnapshot draft, List<ValidationError> errors)
{
var scriptSourceById = draft.Scripts
.GroupBy(s => s.ScriptId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().SourceCode, StringComparer.Ordinal);
foreach (var v in draft.VirtualTags)
{
if (v.TimerIntervalMs is > 0) continue;
var source = scriptSourceById.TryGetValue(v.ScriptId, out var s) ? s : null;
// An unresolved script is reported elsewhere; with no source there are no reads to count.
if (v.ChangeTriggered && source is null) continue;
if (v.ChangeTriggered && EquipmentScriptPaths.ExtractDependencyRefs(source).Count > 0) continue;
errors.Add(new("VirtualTagNoEffectiveTrigger",
v.ChangeTriggered
? $"VirtualTag '{v.VirtualTagId}' is change-triggered but its script has no literal ctx.GetTag read, " +
"and it has no TimerIntervalMs — nothing would ever evaluate it. Add a dependency read or a timer."
: $"VirtualTag '{v.VirtualTagId}' has ChangeTriggered=false and no TimerIntervalMs — nothing would ever evaluate it. " +
"Enable the change trigger or add a timer.",
v.VirtualTagId));
}
}
/// <summary>Read the trigger fields from a calc tag's <c>TagConfig</c>: <c>changeTriggered</c>
/// (default <c>true</c>, the driver's own default) and <c>timerIntervalMs</c> (null when absent or not a
/// number). Never throws — malformed JSON yields the defaults.</summary>
@@ -376,6 +376,8 @@ public sealed record EquipmentTagAlarmInfo(string AlarmType, int Severity, bool?
/// Historize-only toggle is detected as a change. Defaults to <c>false</c> — matching both the CLR
/// default of the <c>bool VirtualTag.Historize</c> column and the artifact-decode default when the flag is absent/non-bool —
/// which keeps existing positional+named ctor call sites compiling and preserves byte-parity.</param>
/// <param name="ChangeTriggered">Evaluate on a dependency change (entity default <c>true</c>).</param>
/// <param name="TimerIntervalMs">Periodic re-evaluation cadence in ms, or null for none (lmxopcua#783).</param>
public sealed record EquipmentVirtualTagPlan(
string VirtualTagId,
string EquipmentId,
@@ -384,7 +386,9 @@ public sealed record EquipmentVirtualTagPlan(
string DataType,
string Expression,
IReadOnlyList<string> DependencyRefs,
bool Historize = false)
bool Historize = false,
bool ChangeTriggered = true,
int? TimerIntervalMs = null)
{
/// <inheritdoc />
// Structural equality: the auto-generated record equality would compare DependencyRefs (an
@@ -400,6 +404,8 @@ public sealed record EquipmentVirtualTagPlan(
DataType == other.DataType &&
Expression == other.Expression &&
Historize == other.Historize &&
ChangeTriggered == other.ChangeTriggered &&
TimerIntervalMs == other.TimerIntervalMs &&
DependencyRefs.SequenceEqual(other.DependencyRefs, StringComparer.Ordinal);
/// <inheritdoc />
@@ -408,7 +414,7 @@ public sealed record EquipmentVirtualTagPlan(
var hash = new HashCode();
hash.Add(VirtualTagId); hash.Add(EquipmentId); hash.Add(FolderPath);
hash.Add(Name); hash.Add(DataType); hash.Add(Expression);
hash.Add(Historize);
hash.Add(Historize); hash.Add(ChangeTriggered); hash.Add(TimerIntervalMs);
foreach (var r in DependencyRefs) hash.Add(r, StringComparer.Ordinal);
return hash.ToHashCode();
}
@@ -625,7 +631,9 @@ public static class AddressSpaceComposer
DataType: v.DataType,
Expression: expanded,
DependencyRefs: EquipmentScriptPaths.ExtractDependencyRefs(expanded),
Historize: v.Historize);
Historize: v.Historize,
ChangeTriggered: v.ChangeTriggered,
TimerIntervalMs: v.TimerIntervalMs);
})
.ToList();
@@ -1416,6 +1416,12 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
&& (hEl.ValueKind == JsonValueKind.True || hEl.ValueKind == JsonValueKind.False)
&& hEl.GetBoolean();
// Triggers (lmxopcua#783): entity defaults when absent/malformed — change-triggered, no timer.
var changeTriggered = !(el.TryGetProperty("ChangeTriggered", out var ctEl)
&& ctEl.ValueKind == JsonValueKind.False);
int? timerMs = el.TryGetProperty("TimerIntervalMs", out var tmEl)
&& tmEl.ValueKind == JsonValueKind.Number && tmEl.TryGetInt32(out var tmv) ? tmv : null;
// Substitute each {{equip}}/<RefName> with the owning equipment's backing RawPath BEFORE
// extracting refs, so both Expression and DependencyRefs are machine-specific — byte-parity
// with AddressSpaceComposer.Compose.
@@ -1430,7 +1436,9 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
DataType: dataType ?? "BaseDataType",
Expression: expanded,
DependencyRefs: EquipmentScriptPaths.ExtractDependencyRefs(expanded),
Historize: historize));
Historize: historize,
ChangeTriggered: changeTriggered,
TimerIntervalMs: timerMs));
}
result.Sort((a, b) =>
@@ -43,6 +43,14 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
/// </summary>
public sealed class VirtualTagActor : ReceiveActor, IWithUnboundedStash
{
/// <summary>Smallest honoured timer cadence in ms — the same floor the DB check constraint, the AdminUI
/// guard and the Calculation driver enforce. A smaller configured value is clamped up to it.</summary>
public const int MinTimerIntervalMs = 50;
/// <summary>Periodic re-evaluation tick (lmxopcua#783). Private to the actor; sent by the scheduler only
/// when the tag has a <c>TimerIntervalMs</c>.</summary>
private sealed class TimerTick { public static readonly TimerTick Instance = new(); private TimerTick() { } }
/// <summary>The asynchronous evaluation started by <see cref="Evaluate"/> finished. Private to the actor.</summary>
private sealed record EvaluationCompleted(
Task<VirtualTagEvalResult> Evaluation, uint WorstInputStatus, DateTime TimestampUtc);
@@ -97,6 +105,9 @@ public sealed class VirtualTagActor : ReceiveActor, IWithUnboundedStash
private readonly IReadOnlyList<string> _dependencyRefs;
private readonly IActorRef? _mux;
private readonly ITelemetryLocalHub? _telemetryHub;
private readonly bool _changeTriggered;
private readonly int? _timerIntervalMs;
private ICancelable? _timer;
// R02:P6 — a script failing on every trigger of a 1 Hz input published one cluster-wide DPS
// ScriptLogEntry per second per tag; 200 such tags shed everyone's entries out of the bounded
// telemetry channels. The per-failure entry is now rate-limited per tag (20/min + one summary).
@@ -141,7 +152,9 @@ public sealed class VirtualTagActor : ReceiveActor, IWithUnboundedStash
IReadOnlyList<string>? dependencyRefs = null,
IActorRef? mux = null,
ITelemetryLocalHub? telemetryHub = null,
ScriptLogRateLimiter? failureLogLimiter = null) =>
ScriptLogRateLimiter? failureLogLimiter = null,
bool changeTriggered = true,
int? timerIntervalMs = null) =>
Akka.Actor.Props.Create(() => new VirtualTagActor(
virtualTagId, expression,
evaluator ?? NullVirtualTagEvaluator.Instance,
@@ -150,7 +163,9 @@ public sealed class VirtualTagActor : ReceiveActor, IWithUnboundedStash
dependencyRefs ?? Array.Empty<string>(),
mux,
telemetryHub,
failureLogLimiter));
failureLogLimiter,
changeTriggered,
timerIntervalMs));
/// <summary>Initializes a virtual tag actor with the given configuration and dependencies.</summary>
/// <param name="virtualTagId">Unique identifier for the virtual tag.</param>
@@ -164,6 +179,10 @@ public sealed class VirtualTagActor : ReceiveActor, IWithUnboundedStash
/// emitted into (Phase 5); null (tests) simply skips the emit.</param>
/// <param name="failureLogLimiter">Rate limiter for this tag's per-failure script-log entries; null ⇒
/// the default 20 per minute + one summary.</param>
/// <param name="changeTriggered">Evaluate when a dependency changes (default). When false only the timer
/// evaluates; dependency values are still tracked.</param>
/// <param name="timerIntervalMs">Periodic re-evaluation cadence in ms (clamped to
/// <see cref="MinTimerIntervalMs"/>), or null for none (lmxopcua#783).</param>
public VirtualTagActor(
string virtualTagId,
string expression,
@@ -173,8 +192,12 @@ public sealed class VirtualTagActor : ReceiveActor, IWithUnboundedStash
IReadOnlyList<string> dependencyRefs,
IActorRef? mux,
ITelemetryLocalHub? telemetryHub = null,
ScriptLogRateLimiter? failureLogLimiter = null)
ScriptLogRateLimiter? failureLogLimiter = null,
bool changeTriggered = true,
int? timerIntervalMs = null)
{
_changeTriggered = changeTriggered;
_timerIntervalMs = timerIntervalMs is > 0 ? Math.Max(timerIntervalMs.Value, MinTimerIntervalMs) : null;
_virtualTagId = virtualTagId;
_scriptId = scriptId;
_expression = expression;
@@ -188,6 +211,7 @@ public sealed class VirtualTagActor : ReceiveActor, IWithUnboundedStash
Receive<DependencyValueChanged>(OnDependencyChanged);
Receive<DependencyValueBatch>(OnDependencyBatch);
Receive<ReassertValue>(_ => OnReassertValue());
Receive<TimerTick>(_ => Evaluate(DateTime.UtcNow));
// Only reachable for a completion that outlived its behaviour (a supervision restart mid-evaluation
// resets the behaviour stack): the incarnation it belonged to is gone, so it is dropped.
Receive<EvaluationCompleted>(_ => { });
@@ -196,6 +220,14 @@ public sealed class VirtualTagActor : ReceiveActor, IWithUnboundedStash
/// <inheritdoc />
protected override void PreStart()
{
if (_timerIntervalMs is { } ms)
{
// lmxopcua#783: timer-driven evaluation. Cancelled in PostStop, so a stop / redeploy respawn
// never leaves a stale ticker behind.
var period = TimeSpan.FromMilliseconds(ms);
_timer = Context.System.Scheduler.ScheduleTellRepeatedlyCancelable(period, period, Self, TimerTick.Instance, Self);
}
if (_mux is not null && _dependencyRefs.Count > 0)
{
// ReplayRetained: a respawned child (plan edit, supervision restart) evaluates from the mux's
@@ -207,6 +239,7 @@ public sealed class VirtualTagActor : ReceiveActor, IWithUnboundedStash
/// <inheritdoc />
protected override void PostStop()
{
_timer?.Cancel();
_mux?.Tell(new DependencyMuxActor.UnregisterInterest(Self));
}
@@ -241,7 +274,7 @@ public sealed class VirtualTagActor : ReceiveActor, IWithUnboundedStash
private void OnDependencyChanged(DependencyValueChanged msg)
{
_dependencies[msg.TagId] = new DataValueSnapshot(msg.Value, msg.StatusCode, msg.TimestampUtc, msg.TimestampUtc);
Evaluate(msg.TimestampUtc);
if (_changeTriggered) Evaluate(msg.TimestampUtc);
}
/// <summary>Applies EVERY item in the batch to the dependency map first, then evaluates the script
@@ -268,7 +301,7 @@ public sealed class VirtualTagActor : ReceiveActor, IWithUnboundedStash
new DataValueSnapshot(item.Value, item.StatusCode, item.TimestampUtc, item.TimestampUtc);
}
Evaluate(items[count - 1].TimestampUtc);
if (_changeTriggered) Evaluate(items[count - 1].TimestampUtc);
}
/// <summary>Recomputes the expression against the current dependency map and emits/dedups the result.
@@ -329,6 +362,9 @@ public sealed class VirtualTagActor : ReceiveActor, IWithUnboundedStash
ApplyEvaluation(msg.Evaluation, msg.WorstInputStatus, msg.TimestampUtc);
Stash.UnstashAll();
});
// A timer tick during a run is dropped, not stashed: the run in flight IS this interval's evaluation,
// and stashing would replay a backlog of ticks (one script run each) after a slow script (#783).
Receive<TimerTick>(_ => { });
ReceiveAny(_ => Stash.Stash());
}
@@ -180,7 +180,9 @@ public sealed class VirtualTagHostActor : ReceiveActor
publisherFactory: null,
dependencyRefs: p.DependencyRefs,
mux: _mux,
telemetryHub: _telemetryHub));
telemetryHub: _telemetryHub,
changeTriggered: p.ChangeTriggered,
timerIntervalMs: p.TimerIntervalMs));
Context.Watch(child);
_children[p.VirtualTagId] = child;
newlySpawned.Add(p.VirtualTagId);