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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user