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>