fix(virtual-tags): evaluate scripts off the Akka dispatcher thread (lmxopcua#779)
VirtualTagActor called the blocking IVirtualTagEvaluator.Evaluate, and RoslynVirtualTagEvaluator blocked on the run guard with .GetAwaiter().GetResult(), so every script run parked a dispatcher thread for up to its 2 s budget (a first-use Roslyn compile did too). This is the #750 P0 refactor for virtual tags. - IVirtualTagEvaluator.EvaluateAsync (default: a completed task over the snapshot overload, so the null evaluator and fakes are unchanged). - RoslynVirtualTagEvaluator implements it without blocking: passthrough and compile failures complete synchronously, a first-use compile goes to the pool, the run is awaited. The blocking overload now delegates to it. - VirtualTagActor: a completed task is applied in the same turn, as before; an incomplete one switches to an "evaluating" behaviour that stashes every dependency/reassert message until a private completion message arrives, then applies the result and unstashes. Each trigger is still evaluated against its own inputs, results are applied/published in trigger order and the dedup/Bad-transition state sees the same sequence. The dependency map is not touched while a run reads it. Calculation and scripted-alarm serialisation is NOT relaxed: neither blocks a thread, and running independent scripts in parallel is not semantics-neutral (pool consumption bound, cross-tag publish order, timer/pump exclusion for Calc; state-machine/persist boundary, transition timestamps and the store-failure path for alarms). Recorded in docs/VirtualTags.md and the #750 design's P0 entry. Tests: VirtualTagActorAsyncEvaluationTests — on a one-thread dispatcher a slow script no longer starves a sibling actor and the slow actor answers Identify (fails when the actor calls the blocking Evaluate); triggers during a run are evaluated afterwards in order against their own inputs; a reassert during a run is applied after it; a faulted evaluation takes the evaluator-threw path (the ordering/reassert tests fail with the stash removed). RoslynVirtualTagEvaluatorAsyncTests — a slow script and a cold compile return an incomplete task (fail without the override); cheap outcomes and timeouts match the blocking overload. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
+3
-1
@@ -96,7 +96,9 @@ Virtual-tag nodes are materialised by `AddressSpaceApplier` and fed by `VirtualT
|
||||
|
||||
`ITagUpstreamSource` and `IHistoryWriter` are the two ports value consumers require from their host. Both live in `Core.VirtualTags`. In the v2 actor system:
|
||||
|
||||
- **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 synchronously on the actor message thread. Reads of never-pushed dependency refs return `null` values in the dependency snapshot.
|
||||
- **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`.
|
||||
- **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)).
|
||||
|
||||
## Composition
|
||||
|
||||
@@ -656,6 +656,10 @@ per-shard state, restarts in the last hour and the quarantine list. Quarantines
|
||||
- Gate: every existing Scripting / VirtualTags / Calculation / ScriptedAlarms suite stays green unchanged,
|
||||
plus new ordering tests for batch-then-apply.
|
||||
- This phase has value on its own: it ends the dispatcher-thread blocking.
|
||||
- **Partly landed in lmxopcua#779:** VT evaluation is async (`IVirtualTagEvaluator.EvaluateAsync` + stash in
|
||||
`VirtualTagActor`), so the dispatcher-thread blocking is gone. The Calc pump and the alarm engine still run one
|
||||
script at a time; why that was not relaxed in-process is recorded in `docs/VirtualTags.md` §"Upstream-tag feed".
|
||||
`IScriptRunner` and batch-submit remain to do.
|
||||
2. **P1, worker + protocol behind the flag.**
|
||||
- Ship `ScriptWorker`, `OutOfProcessScriptRunner` and the PE cache.
|
||||
- Default stays `InProcess`. Enable on the docker-dev rig only.
|
||||
|
||||
Reference in New Issue
Block a user