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