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:
Joseph Doherty
2026-11-09 17:22:49 -05:00
parent d683b83156
commit a84d38707b
7 changed files with 434 additions and 11 deletions
@@ -47,6 +47,29 @@ public interface IVirtualTagEvaluator
foreach (var kv in dependencies) values[kv.Key] = kv.Value?.Value;
return Evaluate(virtualTagId, expression, values);
}
/// <summary>
/// Asynchronous form of the snapshot overload — what <c>VirtualTagActor</c> calls (lmxopcua#779). An
/// implementation whose evaluation can take real time (the Roslyn one: a compile on first use, then a
/// script run of up to its budget) returns an <b>incomplete</b> task instead of blocking the caller, so
/// the actor's dispatcher thread is free while the script runs. The actor applies the result when the
/// task completes, in trigger order.
/// </summary>
/// <remarks>
/// Default-implemented as a synchronously completed task over the snapshot overload, so the null
/// evaluator and every test fake keep their exact behaviour: a completed task is applied inline by the
/// actor, in the same message turn, as before. Must not fault for script failures — those are
/// <see cref="VirtualTagEvalResult.Failure"/>; a faulted task is treated as "evaluator threw".
/// <para>The caller must not mutate <paramref name="dependencies"/> until the task completes: the
/// implementation reads it on another thread while the script runs.</para>
/// </remarks>
/// <param name="virtualTagId">The unique identifier of the virtual tag being evaluated.</param>
/// <param name="expression">The expression string to evaluate.</param>
/// <param name="dependencies">Read-only dictionary of dependency snapshots, keyed by tag path.</param>
/// <returns>The evaluation result.</returns>
Task<VirtualTagEvalResult> EvaluateAsync(
string virtualTagId, string expression, IReadOnlyDictionary<string, DataValueSnapshot> dependencies)
=> Task.FromResult(Evaluate(virtualTagId, expression, dependencies));
}
/// <summary>Result of one virtual-tag expression eval. Stash a Reason on every Failure so