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:
@@ -98,6 +98,7 @@ Virtual-tag nodes are materialised by `AddressSpaceApplier` and fed by `VirtualT
|
||||
|
||||
- **Upstream-tag feed.** `DependencyMuxActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs`) routes the items of each `DriverInstanceActor.AttributeValueBatchPublished` to the `VirtualTagActor` instances that declared interest in those tag refs. Each `VirtualTagActor` holds the in-memory per-tag dependency map; the `IVirtualTagEvaluator` (`RoslynVirtualTagEvaluator`) receives the dependency snapshot through `EvaluateAsync`. Reads of never-pushed dependency refs return `null` values in the dependency snapshot.
|
||||
- **Evaluation is off the dispatcher thread (lmxopcua#779, #750 P0).** The actor used to call the blocking `Evaluate`, which parked an Akka dispatcher thread for the whole run — up to the 2 s budget per evaluation. It now calls `EvaluateAsync`: a passthrough, a compile failure or any synchronous evaluator completes in the same message turn as before, while a real script run (and a first-use compile, which is pushed to the pool) returns an incomplete task. Until it completes the actor **stashes** every further dependency / reassert message, then applies the result and unstashes in arrival order. So each trigger is still evaluated against the inputs as of that trigger, results are published in trigger order, and the dedup / Bad-transition state sees the same sequence — only the thread is freed. Pinned by `VirtualTagActorAsyncEvaluationTests` (a slow script on a one-thread dispatcher no longer starves a sibling actor; ordering, reassert and fault paths) and `RoslynVirtualTagEvaluatorAsyncTests`.
|
||||
- **Timer-driven evaluation (lmxopcua#783).** `VirtualTag.TimerIntervalMs` re-evaluates the tag on a fixed cadence (minimum 50 ms, clamped) via an Akka scheduler owned by `VirtualTagActor` — started in `PreStart`, cancelled in `PostStop`, so a stop or a redeploy respawn never leaves a ticker. `ChangeTriggered=false` makes the timer the only trigger (dependencies are still tracked). A tick that lands while a script run is in flight is dropped rather than stashed, so a slow script never builds a backlog of runs. Before this the field was stored, validated and edited but never consumed, so a timer-only tag sat at `BadWaitingForInitialData` forever. The deploy gate `VirtualTagNoEffectiveTrigger` (mirror of `CalculationNoEffectiveTrigger`) rejects a tag with neither a timer nor a change-trigger with a literal `ctx.GetTag` read. Pinned by `VirtualTagActorTimerTests` and `DraftValidatorVirtualTagTriggerTests`.
|
||||
- **Calculation tags and scripted alarms still evaluate one script at a time per driver / per engine (deliberately, for now).** Neither blocks a thread — both await the run — but a slow script still delays its siblings. Running independent scripts in parallel was considered for #779 and not done, because it is not semantics-neutral: for Calculation, the single pump is what bounds a driver to one pool thread of script work (per-tag lanes would let N slow tags hold N pool threads, the pool Akka's dispatchers also run on, so it needs a bounded-parallelism knob), it would reorder publishes across tags, and the timer path would need per-tag exclusion with the pump; for scripted alarms, the engine gate is also the consistency boundary of the Part 9 state machine, persist-before-update and ack/shelve/load, a predicate's transition time is sampled just before its own run, and a store failure mid-batch today leaves later predicates un-run (in parallel they would already have run, moving their run-guard strike streaks and writing script logs for results that are then discarded). The out-of-process runner's batch-submit `IScriptRunner` (#750) is where that concurrency belongs, with the runner owning the bound.
|
||||
- **`IHistoryWriter`** — the equipment-namespace path threads `Historize` end-to-end and `VirtualTagHostActor` invokes the injected writer on historized results (H5); the writer is resolved through `DriverHostActor` DI with a `NullHistoryWriter` default. No *durable* writer ships on this seam — durable continuous historization now flows through the separate `ContinuousHistorizationRecorder` → HistorianGateway `WriteLiveValues` path (see the `IHistoryWriter` section above and [Historian.md](Historian.md)).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user