Merge local master 7bcb6331 into remed/lane-FOCAS
Refs #740 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -92,7 +92,15 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
// the timer ticks — the mutual exclusion _lock used to provide while it was held across the run, minus
|
||||
// the blocking. Taken ONLY by evaluation workers, so no host thread (mux callback, ReadAsync,
|
||||
// reinit) can ever wait on a script. Lock order is always _evalGate → _lock, never the reverse.
|
||||
private readonly object _evalGate = new();
|
||||
// A SemaphoreSlim rather than a monitor because the holder AWAITS the script run (the pump is async —
|
||||
// it no longer parks a second pool thread behind the one the script runs on, and the run-guard's
|
||||
// budget clock starts only once the pool has picked the script up, so pool starvation is never
|
||||
// reported as a script timeout).
|
||||
private readonly SemaphoreSlim _evalGate = new(1, 1);
|
||||
|
||||
// Cancels an awaited script run on Dispose (the only hard teardown — ShutdownAsync leaves an in-flight
|
||||
// run to its RunTimeout, exactly as before, since the pump drops its result once _running is false).
|
||||
private readonly CancellationTokenSource _evalCts = new();
|
||||
|
||||
// Change-triggered evaluations queued by OnDependencyValue and drained, in arrival order, by a single
|
||||
// pump task. Both fields are guarded by _lock; _pumpScheduled stays true until the pump observes an
|
||||
@@ -103,7 +111,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
private CalculationTimerScheduler? _timers;
|
||||
private volatile bool _running;
|
||||
private DateTime? _lastComputeUtc;
|
||||
private bool _disposed;
|
||||
private volatile bool _disposed;
|
||||
|
||||
/// <summary>Occurs when a calc tag's computed value changes (or transitions Good↔Bad).</summary>
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
@@ -129,7 +137,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
// _logger (ILogger<CalculationDriver>) implements the non-generic ILogger, so it is passed straight
|
||||
// through — the evaluator's host-side diagnostics (compile errors, sandbox violations, execution
|
||||
// throws) land on the SAME logger the driver itself was constructed with, not a separate one.
|
||||
_evaluator = new CalculationEvaluator(_logger, _scriptRoot, _options.RunTimeout);
|
||||
_evaluator = new CalculationEvaluator(_logger, _scriptRoot, _options.RunTimeout, _options.Scripting);
|
||||
|
||||
RebuildTagTable(_options);
|
||||
}
|
||||
@@ -225,6 +233,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
{
|
||||
RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [],
|
||||
RunTimeout = _options.RunTimeout,
|
||||
Scripting = _options.Scripting,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -235,7 +244,10 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
_driverInstanceId);
|
||||
}
|
||||
}
|
||||
return new CalculationDriverOptions { RawTags = _options.RawTags, RunTimeout = _options.RunTimeout };
|
||||
return new CalculationDriverOptions
|
||||
{
|
||||
RawTags = _options.RawTags, RunTimeout = _options.RunTimeout, Scripting = _options.Scripting,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Re-bind the tag table from <paramref name="driverConfigJson"/> — shared by
|
||||
@@ -326,6 +338,11 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
StopTimers();
|
||||
// The pump exits at its next iteration; an evaluation already in flight is bounded by RunTimeout.
|
||||
lock (_lock) _pending.Clear();
|
||||
// R07:L2 — nothing in the runtime disposes a driver, and every config change is a respawn, so
|
||||
// the compiled scripts' collectible ALCs survived each generation until GC got round to the whole
|
||||
// driver graph. Drop them here (idempotent; a later InitializeAsync recompiles on first use). An
|
||||
// in-flight run that races this is already handled by the evaluator's retry-once path.
|
||||
_evaluator.ClearCompiledScripts();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -351,11 +368,15 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
/// <inheritdoc />
|
||||
public void OnDependencyValue(string rawPath, object? value, uint statusCode, DateTime timestampUtc)
|
||||
{
|
||||
if (!_running) return;
|
||||
lock (_lock)
|
||||
{
|
||||
// Recorded REGARDLESS of _running (R07:H6): the mux adapter registers interest at construction,
|
||||
// so inputs — the mux's retained-value replay in particular — can land before InitializeAsync.
|
||||
// Dropping them left every calc tag waiting for each input to change again; keeping them means
|
||||
// the first trigger after init evaluates from current inputs. Only the EVALUATION is gated.
|
||||
_depValues[rawPath] = new DataValueSnapshot(value, statusCode, timestampUtc, timestampUtc);
|
||||
_arrivedDeps.Add(rawPath);
|
||||
if (!_running) return;
|
||||
if (!_changeDependents.TryGetValue(rawPath, out var dependents) || dependents.Count == 0) return;
|
||||
|
||||
// QUEUE each change-triggered dependent — never evaluate here. This
|
||||
@@ -491,13 +512,15 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
{
|
||||
if (_pumpScheduled) return;
|
||||
_pumpScheduled = true;
|
||||
_ = Task.Run(RunEvaluationPump);
|
||||
_ = Task.Run(RunEvaluationPumpAsync);
|
||||
}
|
||||
|
||||
/// <summary>Drain the queued change-triggered evaluations, in arrival order, on a pool thread. At most
|
||||
/// one pump runs at a time, so per-tag evaluation ordering is preserved exactly as it was when the
|
||||
/// change path evaluated inline under the state lock.</summary>
|
||||
private void RunEvaluationPump()
|
||||
/// <summary>Drain the queued change-triggered evaluations, in arrival order. At most one pump runs at a
|
||||
/// time, so per-tag evaluation ordering is preserved exactly as it was when the change path evaluated
|
||||
/// inline under the state lock. Async: the pump AWAITS each script run instead of parking a pool thread
|
||||
/// behind the one the script executes on (P5 — sync-over-async with a nested Task.Run manufactured
|
||||
/// false timeouts under pool starvation).</summary>
|
||||
private async Task RunEvaluationPumpAsync()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
@@ -515,39 +538,69 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
|
||||
// _evalGate (never _lock) is held across the run, so the pump and the timer ticks still
|
||||
// evaluate one at a time while no host thread can block behind a script.
|
||||
lock (_evalGate)
|
||||
if (!await TryEnterEvalGateAsync().ConfigureAwait(false)) return; // disposed
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
RunEvaluation(next);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// The evaluator never throws for script faults, so this is a defect path only — it
|
||||
// must not kill the pump and strand every queued evaluation behind it.
|
||||
_logger.LogError(ex, "Calculation {Driver}: evaluation of {RawPath} failed",
|
||||
_driverInstanceId, next.Definition.RawPath);
|
||||
}
|
||||
await RunEvaluationAsync(next).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// The evaluator never throws for script faults, so this is a defect path only — it
|
||||
// must not kill the pump and strand every queued evaluation behind it.
|
||||
_logger.LogError(ex, "Calculation {Driver}: evaluation of {RawPath} failed",
|
||||
_driverInstanceId, next.Definition.RawPath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_evalGate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Timer-trigger entry point — prepares and evaluates a single calc tag on the scheduler's
|
||||
/// pool thread (the scheduler's per-group in-flight guard is what bounds outstanding timer work).</summary>
|
||||
private void EvaluateTagFromTimer(string rawPath)
|
||||
/// <summary>Timer-trigger entry point — prepares and evaluates a single calc tag (the scheduler's
|
||||
/// per-group in-flight guard is what bounds outstanding timer work; it stays set until this task
|
||||
/// completes).</summary>
|
||||
private async Task EvaluateTagFromTimerAsync(string rawPath)
|
||||
{
|
||||
if (!_running) return;
|
||||
PendingEvaluation? prepared;
|
||||
lock (_lock) prepared = PrepareEvaluation(rawPath, DateTime.UtcNow);
|
||||
if (prepared is null) return;
|
||||
lock (_evalGate) RunEvaluation(prepared);
|
||||
if (!await TryEnterEvalGateAsync().ConfigureAwait(false)) return; // disposed
|
||||
try
|
||||
{
|
||||
await RunEvaluationAsync(prepared).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_evalGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Acquire <see cref="_evalGate"/>; false once <see cref="Dispose"/> has disposed it (a pump
|
||||
/// iteration or timer tick that raced the teardown simply stops).</summary>
|
||||
private async Task<bool> TryEnterEvalGateAsync()
|
||||
{
|
||||
if (_disposed) return false;
|
||||
try
|
||||
{
|
||||
await _evalGate.WaitAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!_disposed) return true;
|
||||
// Dispose landed while we waited (it gave up the gate without disposing it): back out.
|
||||
_evalGate.Release();
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Run one prepared evaluation and publish the outcome. MUST be called under
|
||||
/// <see cref="_evalGate"/> and NOT under <see cref="_lock"/>: the script run is the whole point of the
|
||||
/// split, and the script log + <see cref="OnDataChange"/> fan-out both invoke subscriber code.</summary>
|
||||
/// <param name="pending">The prepared evaluation.</param>
|
||||
private void RunEvaluation(PendingEvaluation pending)
|
||||
private async Task RunEvaluationAsync(PendingEvaluation pending)
|
||||
{
|
||||
var def = pending.Definition;
|
||||
|
||||
@@ -572,9 +625,10 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
|
||||
// Snapshots, not bare values: the script's ctx.GetTag(path).StatusCode / .SourceTimestampUtc are
|
||||
// then the input's real ones rather than a fabricated Good stamped at evaluation time.
|
||||
var result = _evaluator.Evaluate(def.RawPath, def.ScriptSource, pending.Dependencies);
|
||||
var result = await _evaluator.EvaluateAsync(def.RawPath, def.ScriptSource, pending.Dependencies, _evalCts.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
string? failureReason = null;
|
||||
string? failureToLog = null;
|
||||
(object? Value, uint StatusCode)? toPublish;
|
||||
lock (_lock)
|
||||
{
|
||||
@@ -584,16 +638,24 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
failureReason = result.Reason ?? "evaluator failure";
|
||||
var reason = result.Reason ?? "evaluator failure";
|
||||
toPublish = MarkEvaluationFailed(st, pending.TimestampUtc);
|
||||
// The script-log entry fires on the Good→Bad TRANSITION, or when the failure changes
|
||||
// (R07:P4) — not on every trigger of a tag that is already Bad for the same reason, which
|
||||
// flooded the script log at the input's cadence. The Bad publish was already once per
|
||||
// transition; the log now matches it.
|
||||
if (toPublish is not null || !string.Equals(st.LastFailureReason, reason, StringComparison.Ordinal))
|
||||
failureToLog = reason;
|
||||
st.LastFailureReason = reason;
|
||||
}
|
||||
else
|
||||
{
|
||||
st.LastFailureReason = null;
|
||||
toPublish = MarkEvaluationSucceeded(st, result.Value, pending.WorstInputStatus, pending.TimestampUtc);
|
||||
}
|
||||
}
|
||||
|
||||
if (failureReason is not null) PublishScriptLog(def, failureReason);
|
||||
if (failureToLog is not null) PublishScriptLog(def, failureToLog);
|
||||
if (toPublish is { } p) Publish(def.RawPath, p.Value, p.StatusCode, pending.TimestampUtc);
|
||||
}
|
||||
|
||||
@@ -612,7 +674,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
private (object? Value, uint StatusCode)? MarkEvaluationSucceeded(
|
||||
TagState st, object? value, uint statusCode, DateTime timestampUtc)
|
||||
{
|
||||
if (st.HasPublished && st.LastPublishedStatus == statusCode && st.HasValue && Equals(st.LastValue, value))
|
||||
if (st.HasPublished && st.LastPublishedStatus == statusCode && st.HasValue && ValuesEqual(st.LastValue, value))
|
||||
return null;
|
||||
|
||||
st.HasValue = true;
|
||||
@@ -624,6 +686,17 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
return (value, statusCode);
|
||||
}
|
||||
|
||||
/// <summary>Value equality for the dedup: structural for arrays (R07:H8 — <c>object.Equals</c> is reference
|
||||
/// equality for a <c>double[]</c>, so an array-returning timer script republished every tick through
|
||||
/// the dual-namespace fan-out and the historian), ordinary <see cref="object.Equals(object?, object?)"/>
|
||||
/// otherwise.</summary>
|
||||
private static bool ValuesEqual(object? a, object? b)
|
||||
{
|
||||
if (a is Array x && b is Array y)
|
||||
return System.Collections.StructuralComparisons.StructuralEqualityComparer.Equals(x, y);
|
||||
return Equals(a, b);
|
||||
}
|
||||
|
||||
/// <summary>Record that this tag's INPUTS are Bad, so no evaluation ran. Publishes the
|
||||
/// worst input's status — carrying the last known value — <b>once per change of that status</b>: a
|
||||
/// repeat of the same Bad is silent, while <c>BadNoCommunication</c> arriving over an earlier
|
||||
@@ -688,7 +761,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
{
|
||||
var timed = _tagsByRawPath.Values.Where(t => t.TimerInterval is not null).ToArray();
|
||||
if (timed.Length == 0) return;
|
||||
_timers = new CalculationTimerScheduler(EvaluateTagFromTimer, _logger);
|
||||
_timers = new CalculationTimerScheduler(EvaluateTagFromTimerAsync, _logger);
|
||||
_timers.Start(timed);
|
||||
}
|
||||
|
||||
@@ -701,6 +774,12 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
/// <summary>Test/diagnostic accessor: number of timer ticks skipped for in-flight groups.</summary>
|
||||
internal long TimerSkippedTickCount => _timers?.SkippedTickCount ?? 0;
|
||||
|
||||
/// <summary>Test/diagnostic accessor: sources currently remembered as failing to compile.</summary>
|
||||
internal int CompileFailureCountForTest => _evaluator.CompileFailureCount;
|
||||
|
||||
/// <summary>Test/diagnostic accessor: compiled scripts currently held by the evaluator's cache.</summary>
|
||||
internal int CompiledScriptCountForTest => _evaluator.CompiledScriptCount;
|
||||
|
||||
/// <summary>Test/diagnostic accessor: <see langword="true"/> when no change-triggered evaluation is
|
||||
/// queued or in flight. A test that used to assert straight after <see cref="OnDependencyValue"/> —
|
||||
/// which evaluated inline — waits on this instead, so the assertion still runs against a settled
|
||||
@@ -723,9 +802,34 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
_running = false;
|
||||
StopTimers();
|
||||
lock (_lock) _pending.Clear();
|
||||
// Cancel an awaited run first (the guard lets the caller's cancel win over the timeout), then drop
|
||||
// the compiled scripts.
|
||||
try { _evalCts.Cancel(); } catch (ObjectDisposedException) { }
|
||||
_evaluator.Dispose();
|
||||
|
||||
// Then take the gate: once Dispose holds it no evaluation is mid-run (the token — read only under
|
||||
// the gate — has been observed), so the gate and the CTS can be disposed. A late pump iteration or
|
||||
// timer tick sees _disposed / an ObjectDisposedException in TryEnterEvalGateAsync and stops. Bounded:
|
||||
// a run that ignores the cancel cannot hold Dispose; in that case both are left to the GC.
|
||||
if (_evalGate.Wait(DisposeGateTimeout))
|
||||
{
|
||||
_evalCts.Dispose();
|
||||
_evalGate.Dispose();
|
||||
_evalResourcesDisposed = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Calculation {Driver}: an evaluation did not release the gate within {Timeout} of Dispose; leaving it to the GC",
|
||||
_driverInstanceId, DisposeGateTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly TimeSpan DisposeGateTimeout = TimeSpan.FromSeconds(2);
|
||||
private volatile bool _evalResourcesDisposed;
|
||||
|
||||
/// <summary>Test hook — whether <see cref="Dispose"/> disposed the evaluation gate and its CTS.</summary>
|
||||
internal bool EvalResourcesDisposedForTest => _evalResourcesDisposed;
|
||||
|
||||
/// <summary>Per-tag published state (guarded by <see cref="_lock"/>).</summary>
|
||||
private sealed class TagState
|
||||
{
|
||||
@@ -741,6 +845,10 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
/// now carries the failing INPUT's sub-code rather than a fixed <c>BadInternalError</c>.</summary>
|
||||
public uint LastPublishedStatus;
|
||||
public DateTime LastTimestampUtc;
|
||||
|
||||
/// <summary>The reason of the last failed evaluation (null after a success / inputs-Bad hold) — the
|
||||
/// gate that keeps the script-log entry to the transition and to a CHANGE of reason.</summary>
|
||||
public string? LastFailureReason;
|
||||
}
|
||||
|
||||
private sealed record CalculationSubscriptionHandle(string DiagnosticId) : ISubscriptionHandle;
|
||||
|
||||
+17
@@ -71,11 +71,22 @@ public static class CalculationDriverFactoryExtensions
|
||||
?? throw new InvalidOperationException(
|
||||
$"Calculation driver config for '{driverInstanceId}' deserialised to null");
|
||||
|
||||
// Run-guard knobs are optional and default to the node-wide ScriptingOptions defaults; only an
|
||||
// explicitly authored key overrides one (so a config carrying neither binds Scripting = null).
|
||||
ScriptingOptions? scripting = null;
|
||||
if (dto.MaxAllocBytesPerRun is not null || dto.QuarantineAfterConsecutiveTimeouts is not null)
|
||||
{
|
||||
scripting = new ScriptingOptions();
|
||||
if (dto.MaxAllocBytesPerRun is { } alloc) scripting.MaxAllocBytesPerRun = alloc;
|
||||
if (dto.QuarantineAfterConsecutiveTimeouts is { } n) scripting.QuarantineAfterConsecutiveTimeouts = n;
|
||||
}
|
||||
|
||||
var options = new CalculationDriverOptions
|
||||
{
|
||||
RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [],
|
||||
RunTimeout = dto.RunTimeoutMs is { } ms && ms > 0
|
||||
? TimeSpan.FromMilliseconds(ms) : TimeSpan.FromSeconds(2),
|
||||
Scripting = scripting,
|
||||
};
|
||||
|
||||
return new CalculationDriver(
|
||||
@@ -92,5 +103,11 @@ public static class CalculationDriverFactoryExtensions
|
||||
|
||||
/// <summary>Optional per-script evaluation wall-clock budget override, in milliseconds (default 2000).</summary>
|
||||
public int? RunTimeoutMs { get; init; }
|
||||
|
||||
/// <summary>Optional run-guard allocation budget per script run, in bytes (default 64 MB; ≤ 0 disables).</summary>
|
||||
public long? MaxAllocBytesPerRun { get; init; }
|
||||
|
||||
/// <summary>Optional run-guard quarantine threshold: consecutive timeouts (refusals do not count) before a tag's script stops being evaluated (default 3).</summary>
|
||||
public int? QuarantineAfterConsecutiveTimeouts { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||
|
||||
/// <summary>
|
||||
/// Bound options for a <see cref="CalculationDriver"/> instance. The Calculation pseudo-driver has
|
||||
/// no backend/endpoint config — its only input is the authored raw calc tags the deploy artifact
|
||||
/// injects as the <see cref="RawTags"/> list (each carrying the tag's <c>scriptId</c> + resolved
|
||||
/// <c>scriptSource</c> + trigger config inside its <see cref="RawTagEntry.TagConfig"/> blob).
|
||||
/// </summary>
|
||||
/// <summary>Bound options for one <see cref="CalculationDriver"/> instance.</summary>
|
||||
public sealed class CalculationDriverOptions
|
||||
{
|
||||
/// <summary>The driver's authored raw calc tags (RawPath + TagConfig blob + WriteIdempotent), from
|
||||
/// the deploy artifact. Each is mapped to a <see cref="CalculationTagDefinition"/> at construction.</summary>
|
||||
/// <summary>The authored raw calc tags (RawPath + TagConfig JSON) this driver computes.</summary>
|
||||
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = Array.Empty<RawTagEntry>();
|
||||
|
||||
/// <summary>Per-script wall-clock evaluation budget handed to the <see cref="CalculationEvaluator"/>.
|
||||
/// Defaults to 2 seconds (parity with the VirtualTag evaluator).</summary>
|
||||
/// <summary>Wall-clock budget per script run (driver-config <c>runTimeoutMs</c>); default 2 s.</summary>
|
||||
public TimeSpan RunTimeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>
|
||||
/// Run-guard budget + quarantine threshold for this driver's scripts (driver-config
|
||||
/// <c>maxAllocBytesPerRun</c> / <c>quarantineAfterConsecutiveTimeouts</c>). Null ⇒ the
|
||||
/// <see cref="ScriptingOptions"/> defaults (64 MB, 3).
|
||||
/// </summary>
|
||||
public ScriptingOptions? Scripting { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
@@ -37,8 +38,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||
/// apply-boundary clear, which is what stops collectible <c>AssemblyLoadContext</c>s accreting across
|
||||
/// script edits.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Run quarantine.</b> Every compiled run goes through one <see cref="ScriptRunGuard"/> keyed by
|
||||
/// calc-tag RawPath with the script source as the identity: a run is refused while the tag's previous
|
||||
/// (timed-out, abandoned) run is still executing, and the tag is quarantined after three consecutive
|
||||
/// timeouts (a refusal is Bad but not a strike, unless its orphan is stuck past StuckOrphanQuarantineFactor × the budget) or an allocation-budget breach — both surface as a <see cref="VirtualTagEvalResult.Failure"/>
|
||||
/// the driver maps to Bad. The driver's evaluation pump awaits <see cref="EvaluateAsync"/> rather than
|
||||
/// blocking a pool thread on the sync <see cref="Evaluate(string, string, IReadOnlyDictionary{string, DataValueSnapshot})"/>,
|
||||
/// and the guard starts the budget clock only once the pool has picked the script up, so pool starvation
|
||||
/// is never reported as a script timeout.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class CalculationEvaluator : IScriptCacheOwner, IDisposable
|
||||
public sealed class CalculationEvaluator : IScriptCacheOwner, IScriptQuarantineOwner, IDisposable
|
||||
{
|
||||
// Source-hash-keyed compile cache: Lazy single-compile (no double-compile race) and Clear()/Dispose()
|
||||
// that dispose each evaluator's collectible AssemblyLoadContext. ClearCompiledScripts() — driven by the
|
||||
@@ -47,6 +58,14 @@ public sealed class CalculationEvaluator : IScriptCacheOwner, IDisposable
|
||||
private readonly ILogger _logger;
|
||||
private readonly SerilogLogger _scriptRoot;
|
||||
private readonly TimeSpan _runTimeout;
|
||||
private readonly ScriptRunGuard _guard;
|
||||
|
||||
// Negative compile cache (R07:P4), keyed by source hash. A compile error is a property of the SOURCE,
|
||||
// which never changes between triggers (it is fixed in the deploy artifact) — yet every trigger of a
|
||||
// broken tag used to re-run a full Roslyn compile under the driver-wide evaluation gate, starving the
|
||||
// healthy tags behind it, and log the same Warning each time. The failure is now remembered until the
|
||||
// next ClearCompiledScripts (a redeploy), and the host Warning fires once per source.
|
||||
private readonly ConcurrentDictionary<string, string> _compileFailures = new(StringComparer.Ordinal);
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Initializes a new <see cref="CalculationEvaluator"/>.</summary>
|
||||
@@ -56,16 +75,22 @@ public sealed class CalculationEvaluator : IScriptCacheOwner, IDisposable
|
||||
/// implements this — instead of needing a second, separately-obtained logger instance.</param>
|
||||
/// <param name="scriptRoot">Root script logger; user <c>ctx.Logger.*</c> output flows through this to the Script-log page.</param>
|
||||
/// <param name="runTimeout">Wall-clock budget per script run; defaults to 2 seconds (parity with the VT evaluator).</param>
|
||||
/// <param name="scripting">Run-guard budget + quarantine threshold; null ⇒ <see cref="ScriptingOptions"/> defaults.</param>
|
||||
public CalculationEvaluator(
|
||||
ILogger logger,
|
||||
ScriptRootLogger scriptRoot,
|
||||
TimeSpan? runTimeout = null)
|
||||
TimeSpan? runTimeout = null,
|
||||
ScriptingOptions? scripting = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_scriptRoot = (scriptRoot ?? throw new ArgumentNullException(nameof(scriptRoot))).Logger;
|
||||
_runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
|
||||
_guard = new ScriptRunGuard(scripting, _scriptRoot);
|
||||
}
|
||||
|
||||
/// <summary>The run guard every compiled evaluation goes through (diagnostics / tests).</summary>
|
||||
public ScriptRunGuard RunGuard => _guard;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluate <paramref name="scriptSource"/> for the calc tag <paramref name="calcTagId"/> against
|
||||
/// the snapshot in <paramref name="dependencies"/> (RawPath → last value). Never throws — script
|
||||
@@ -112,8 +137,25 @@ public sealed class CalculationEvaluator : IScriptCacheOwner, IDisposable
|
||||
/// <param name="scriptSource">The C# script source producing the tag's value.</param>
|
||||
/// <param name="dependencies">Read-only snapshot of dependency values keyed by RawPath.</param>
|
||||
/// <returns>The evaluation outcome.</returns>
|
||||
/// <remarks>Blocks the calling thread for the run. The driver's pump uses <see cref="EvaluateAsync"/>;
|
||||
/// this overload remains for callers with no async context — chiefly tests.</remarks>
|
||||
public VirtualTagEvalResult Evaluate(
|
||||
string calcTagId, string scriptSource, IReadOnlyDictionary<string, DataValueSnapshot> dependencies)
|
||||
=> EvaluateAsync(calcTagId, scriptSource, dependencies).GetAwaiter().GetResult();
|
||||
|
||||
/// <summary>
|
||||
/// The async form of <see cref="Evaluate(string, string, IReadOnlyDictionary{string, DataValueSnapshot})"/>:
|
||||
/// the compiled script runs on the pool under the <see cref="ScriptRunGuard"/> and the caller awaits
|
||||
/// it instead of blocking a second pool thread for the duration. Never throws for script faults.
|
||||
/// </summary>
|
||||
/// <param name="calcTagId">Identity of the calc tag (bound to the script log).</param>
|
||||
/// <param name="scriptSource">The C# script source producing the tag's value.</param>
|
||||
/// <param name="dependencies">Read-only snapshot of dependency values keyed by RawPath.</param>
|
||||
/// <param name="ct">Caller cancellation (driver shutdown); wins over the run timeout.</param>
|
||||
/// <returns>The evaluation outcome.</returns>
|
||||
public async Task<VirtualTagEvalResult> EvaluateAsync(
|
||||
string calcTagId, string scriptSource, IReadOnlyDictionary<string, DataValueSnapshot> dependencies,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (_disposed) return VirtualTagEvalResult.Failure("evaluator disposed");
|
||||
if (string.IsNullOrWhiteSpace(scriptSource)) return VirtualTagEvalResult.Failure("empty expression");
|
||||
@@ -146,6 +188,11 @@ public sealed class CalculationEvaluator : IScriptCacheOwner, IDisposable
|
||||
calcTagId, path),
|
||||
logger: scriptLog);
|
||||
|
||||
// A source that already failed to compile fails the same way without touching Roslyn (R07:P4).
|
||||
var sourceKey = HashSource(scriptSource);
|
||||
if (_compileFailures.TryGetValue(sourceKey, out var knownFailure))
|
||||
return VirtualTagEvalResult.Failure(knownFailure);
|
||||
|
||||
// Fetch + run inside a bounded loop so an ObjectDisposedException caused by a concurrent
|
||||
// ClearCompiledScripts (which disposed the evaluator between GetOrCompile and the run's
|
||||
// disposed guard) re-fetches and retries exactly once. The loop runs at most twice.
|
||||
@@ -158,13 +205,11 @@ public sealed class CalculationEvaluator : IScriptCacheOwner, IDisposable
|
||||
}
|
||||
catch (CompilationErrorException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Calculation {Id}: Roslyn compile failed", calcTagId);
|
||||
return VirtualTagEvalResult.Failure($"compile error: {ex.Message}");
|
||||
return RememberCompileFailure(sourceKey, calcTagId, $"compile error: {ex.Message}", ex, "Roslyn compile failed");
|
||||
}
|
||||
catch (ScriptSandboxViolationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Calculation {Id}: sandbox violation", calcTagId);
|
||||
return VirtualTagEvalResult.Failure($"sandbox violation: {ex.Message}");
|
||||
return RememberCompileFailure(sourceKey, calcTagId, $"sandbox violation: {ex.Message}", ex, "sandbox violation");
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
@@ -180,11 +225,12 @@ public sealed class CalculationEvaluator : IScriptCacheOwner, IDisposable
|
||||
|
||||
try
|
||||
{
|
||||
// Route through TimedScriptEvaluator (Task.Run + WaitAsync): a raw CancellationToken can't
|
||||
// interrupt a CPU-bound/infinite-loop Roslyn script, so the timed wrapper is what bounds a
|
||||
// runaway script to the wall-clock budget instead of wedging the driver's dispatch.
|
||||
var timed = new TimedScriptEvaluator<VirtualTagContext, object?>(evaluator, _runTimeout);
|
||||
var raw = timed.RunAsync(context).GetAwaiter().GetResult();
|
||||
// Route through the ScriptRunGuard (Task.Run + WaitAsync, keyed by RawPath): a raw
|
||||
// CancellationToken can't interrupt a CPU-bound/infinite-loop Roslyn script, so the wall-clock
|
||||
// budget is what bounds a runaway script — and the guard is what keeps the next trigger from
|
||||
// stacking another orphan behind it (one per tag, then quarantine).
|
||||
var raw = await _guard.RunAsync(calcTagId, scriptSource, evaluator, context, _runTimeout, ct)
|
||||
.ConfigureAwait(false);
|
||||
return VirtualTagEvalResult.Ok(raw);
|
||||
}
|
||||
catch (ScriptTimeoutException)
|
||||
@@ -192,6 +238,18 @@ public sealed class CalculationEvaluator : IScriptCacheOwner, IDisposable
|
||||
return VirtualTagEvalResult.Failure(
|
||||
$"{ScriptEvalFailureReasons.TimedOutPrefix}{_runTimeout.TotalSeconds:F1}s");
|
||||
}
|
||||
catch (ScriptRunRefusedException ex)
|
||||
{
|
||||
return VirtualTagEvalResult.Failure(ex.Message);
|
||||
}
|
||||
catch (ScriptQuarantinedException ex)
|
||||
{
|
||||
return VirtualTagEvalResult.Failure(ex.Message);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
return VirtualTagEvalResult.Failure("evaluation cancelled (driver shutting down)");
|
||||
}
|
||||
catch (ObjectDisposedException) when (!_disposed && attempt == 0)
|
||||
{
|
||||
// A concurrent ClearCompiledScripts disposed the evaluator between our GetOrCompile and the
|
||||
@@ -211,8 +269,39 @@ public sealed class CalculationEvaluator : IScriptCacheOwner, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Record a compile failure for <paramref name="sourceKey"/> and log it ONCE — the next trigger
|
||||
/// of the same source returns the remembered reason without a Roslyn round-trip or a second Warning.</summary>
|
||||
private VirtualTagEvalResult RememberCompileFailure(string sourceKey, string calcTagId, string reason, Exception ex, string what)
|
||||
{
|
||||
if (_compileFailures.TryAdd(sourceKey, reason))
|
||||
_logger.LogWarning(ex, "Calculation {Id}: {What} (remembered until the next redeploy)", calcTagId, what);
|
||||
return VirtualTagEvalResult.Failure(reason);
|
||||
}
|
||||
|
||||
private static string HashSource(string source)
|
||||
=> Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(source)));
|
||||
|
||||
/// <summary>Test/diagnostic: the number of sources currently remembered as failing to compile.</summary>
|
||||
public int CompileFailureCount => _compileFailures.Count;
|
||||
|
||||
/// <summary>Test/diagnostic: the number of compiled scripts currently cached.</summary>
|
||||
public int CompiledScriptCount => _cache.Count;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ClearCompiledScripts() => _cache.Clear();
|
||||
public void ClearCompiledScripts()
|
||||
{
|
||||
_cache.Clear();
|
||||
_compileFailures.Clear();
|
||||
// The compile-cache clear is this evaluator's deploy boundary — a redeploy lifts every quarantine
|
||||
// (an edited source already lifts its own through the guard's source identity).
|
||||
_guard.ResetAll();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ResetQuarantines() => _guard.ResetAll();
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyCollection<string> QuarantinedScripts => _guard.QuarantinedKeys;
|
||||
|
||||
/// <summary>Disposes the evaluator, draining + disposing every cached compile's collectible ALC.</summary>
|
||||
public void Dispose()
|
||||
@@ -220,5 +309,6 @@ public sealed class CalculationEvaluator : IScriptCacheOwner, IDisposable
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_cache.Dispose();
|
||||
_compileFailures.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||
/// <see cref="System.Threading.Timer"/> per distinct interval group (so the wire count stays low
|
||||
/// regardless of tag count) with a per-group in-flight flag that skips a tick when the prior tick for
|
||||
/// the same group is still running. Each tick invokes the driver's own evaluate
|
||||
/// callback per tag in the group.
|
||||
/// callback per tag in the group and AWAITS it — the in-flight flag is held across the awaited script
|
||||
/// run, not just across the synchronous part of the callback, so a slow script still bounds its group
|
||||
/// to one outstanding tick.
|
||||
/// </summary>
|
||||
internal sealed class CalculationTimerScheduler : IDisposable
|
||||
{
|
||||
private readonly Action<string> _evaluate;
|
||||
private readonly Func<string, Task> _evaluate;
|
||||
private readonly ILogger _logger;
|
||||
private readonly List<Timer> _timers = [];
|
||||
private readonly List<TickGroup> _groups = [];
|
||||
@@ -20,9 +22,9 @@ internal sealed class CalculationTimerScheduler : IDisposable
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="CalculationTimerScheduler"/> class.</summary>
|
||||
/// <param name="evaluate">Callback invoked with a calc tag's RawPath when its timer group ticks.</param>
|
||||
/// <param name="evaluate">Callback invoked with a calc tag's RawPath when its timer group ticks; awaited.</param>
|
||||
/// <param name="logger">Logger for diagnostics.</param>
|
||||
public CalculationTimerScheduler(Action<string> evaluate, ILogger logger)
|
||||
public CalculationTimerScheduler(Func<string, Task> evaluate, ILogger logger)
|
||||
{
|
||||
_evaluate = evaluate ?? throw new ArgumentNullException(nameof(evaluate));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
@@ -69,12 +71,20 @@ internal sealed class CalculationTimerScheduler : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
// The Timer callback is void; the tick itself is async so the awaited script runs do not pin this
|
||||
// pool thread. TickAsync owns the in-flight flag and swallows everything — nothing can escape to the
|
||||
// Timer (which would be an unobserved fault on a timer thread).
|
||||
_ = TickAsync(ctx);
|
||||
}
|
||||
|
||||
private async Task TickAsync(TickGroup ctx)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var path in ctx.Paths)
|
||||
{
|
||||
if (_cts.IsCancellationRequested) return;
|
||||
try { _evaluate(path); }
|
||||
try { await _evaluate(path).ConfigureAwait(false); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "CalculationTimerScheduler evaluate failed for {Path}", path); }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user