using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
///
/// The Calculation pseudo-driver. Its tags are ordinary raw tags whose value is computed
/// by a C# script over other tags' live values — there is no backend, no endpoint, no discovery, no
/// writes. It implements:
///
/// - — lifecycle + always-Connected health.
/// - — the host feeds it the other tags' live values.
/// - — computed values flow out via
/// exactly like any driver, so the ordinary node fan-out + the mux re-entry (calc-of-calc) apply.
/// - — on-demand read returns the last computed snapshot
/// (BadWaitingForInitialData before the first evaluation).
///
///
///
/// Triggers. A tag re-evaluates on any of its declared dependencies changing (change-trigger,
/// gated exactly like VirtualTagActor: nothing publishes until every declared dep has arrived
/// at least once, and equal results are deduped) and/or on a timer (one timer per interval group).
///
///
/// Where scripts run. is called
/// directly and synchronously by DependencyConsumerMuxAdapter — on an Akka actor's own
/// dispatcher thread — so it does no evaluation itself: it records the value, prepares the
/// affected tags (definition + dependency snapshot, taken at trigger time) and hands them to a
/// single serialized evaluation pump. Script execution therefore never runs on the mux
/// callback thread and never runs under ; the pump and the timer ticks are
/// serialized against each other by instead, which preserves the
/// one-evaluation-at-a-time ordering the state lock used to provide.
///
///
/// Error semantics (mini-design §6). An evaluator Failure publishes Bad quality
/// carrying the last-known value, once per Good→Bad transition and only after all deps have arrived,
/// and emits a ScriptLogEntry on the script-logs topic (via the injected
/// 's ScriptLogTopicSink). Recovery to Good is force-published.
/// A historized Bad records BadInternalError.
///
///
public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscribable, IReadable, ISkippedTagReporter, IDisposable
{
/// Authored tags this driver could not build into its calc table. A raw tag with
/// no scriptId is not a calc tag and computes nothing.
private readonly SkippedTagLedger _skippedTags = new();
///
public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
// OPC UA status codes surfaced by the calc driver.
private const uint StatusGood = 0u;
private const uint StatusBadWaitingForInitialData = 0x80320000u;
private const uint StatusBadInternalError = 0x80020000u;
private readonly string _driverInstanceId;
private readonly CalculationDriverOptions _options;
private readonly ILogger _logger;
private readonly ScriptRootLogger _scriptRoot;
private readonly CalculationEvaluator _evaluator;
// Authored calc tags, keyed by RawPath (identity + evaluation id). Built at construction so
// DependencyRefs is available BEFORE InitializeAsync (the host reads it when spawning the mux-adapter),
// and REBUILT on every ReinitializeAsync / body-carrying InitializeAsync (an in-place re-bind of an edited calc-tag set).
private readonly Dictionary _tagsByRawPath = new(StringComparer.Ordinal);
// rawPath (a dependency) → the calc tags that read it AND are change-triggered. Drives OnDependencyValue.
private readonly Dictionary> _changeDependents = new(StringComparer.Ordinal);
// Union of every tag's declared dependency refs — the interest set the host registers on the mux.
// Reassigned by RebuildTagTable so a redeploy that adds/removes dependencies re-registers on the mux.
private IReadOnlyCollection _dependencyRefs = Array.Empty();
// Live evaluation state. Guarded by _lock: OnDependencyValue (the mux actor's thread), timer ticks
// and the evaluation pump (pool threads) all touch it, so state mutation is serialized. _lock is a
// SHORT-HELD state lock — it is taken for dictionary work only and is NEVER held across a script run.
private readonly object _lock = new();
// Dependency SNAPSHOTS, not bare values: the status code is what tells a
// comms-lost input apart from a genuine Good null, and storing values only is what let this driver
// compute a Good output from a dead source.
private readonly Dictionary _depValues = new(StringComparer.Ordinal);
private readonly HashSet _arrivedDeps = new(StringComparer.Ordinal);
private readonly Dictionary _stateByRawPath = new(StringComparer.Ordinal);
// Serializes script EXECUTION (and the result-apply that follows it) across the evaluation pump and
// 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.
// 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
// empty queue under the lock, so an enqueue can never be lost against a pump that is exiting.
private readonly Queue _pending = new();
private bool _pumpScheduled;
private CalculationTimerScheduler? _timers;
private volatile bool _running;
private DateTime? _lastComputeUtc;
private volatile bool _disposed;
/// Occurs when a calc tag's computed value changes (or transitions Good↔Bad).
public event EventHandler? OnDataChange;
/// Initializes a new .
/// Bound options carrying the authored raw calc tags.
/// Stable logical id of this driver instance.
/// Root script logger — user ctx.Logger output + failure entries fan out
/// to the script-logs topic through its ScriptLogTopicSink. When null a no-op logger is used.
/// Host-side diagnostics logger; defaults to the null logger.
public CalculationDriver(
CalculationDriverOptions options,
string driverInstanceId,
ScriptRootLogger? scriptRoot = null,
ILogger? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
_options = options;
_driverInstanceId = driverInstanceId;
_logger = logger ?? NullLogger.Instance;
_scriptRoot = scriptRoot ?? new ScriptRootLogger(new LoggerConfiguration().CreateLogger());
// _logger (ILogger) 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, _options.Scripting);
RebuildTagTable(_options);
}
///
/// (Re)build the authored tag table (), the change-trigger fan-out
/// (), the dependency-ref interest set (),
/// and the per-tag state map () from . Called
/// once at construction AND on every so an in-place config change — a
/// calc-tag add/remove/edit, or a script-source edit — actually takes effect. Extracted so the ctor
/// and reinit paths share ONE construction routine and cannot drift.
/// Per-tag is preserved for a tag that survives the edit (cheap last-known
/// value carry-over); a brand-new tag starts fresh; a removed tag's state is dropped. MUST be called
/// under when invoked post-construction (the ctor runs single-threaded).
///
/// The (new) bound options carrying the authored raw calc tags.
private void RebuildTagTable(CalculationDriverOptions config)
{
_tagsByRawPath.Clear();
_changeDependents.Clear();
// Retain surviving tags' state; anything not re-authored is dropped when we swap this in below.
var retainedState = new Dictionary(StringComparer.Ordinal);
_skippedTags.BeginRebuild(config.RawTags.Count);
foreach (var entry in config.RawTags)
{
if (!CalculationTagDefinition.TryFromTagConfig(entry.TagConfig, entry.RawPath, out var def))
{
_skippedTags.Skip(entry.RawPath, "no scriptId — not a calc tag");
_logger.LogWarning(
"Calculation {Driver}: raw tag {RawPath} has no scriptId; skipping (not a calc tag)",
_driverInstanceId, entry.RawPath);
continue;
}
if (string.IsNullOrWhiteSpace(def.ScriptSource))
{
_logger.LogWarning(
"Calculation {Driver}: calc tag {RawPath} (script {Script}) has no resolved scriptSource; " +
"it will not compute until the deploy artifact injects the script source",
_driverInstanceId, entry.RawPath, def.ScriptId);
}
_tagsByRawPath[entry.RawPath] = def;
// Carry the last-known state forward for a surviving tag; a brand-new tag starts fresh.
retainedState[entry.RawPath] =
_stateByRawPath.TryGetValue(entry.RawPath, out var prior) ? prior : new TagState();
if (def.ChangeTriggered)
{
foreach (var dep in def.DependencyRefs)
{
if (!_changeDependents.TryGetValue(dep, out var list))
_changeDependents[dep] = list = new List();
if (!list.Contains(entry.RawPath, StringComparer.Ordinal)) list.Add(entry.RawPath);
}
}
}
_skippedTags.Commit();
// Swap in the retained/new state map — a removed tag's state is dropped so it stops publishing.
_stateByRawPath.Clear();
foreach (var (rawPath, state) in retainedState) _stateByRawPath[rawPath] = state;
_dependencyRefs = _tagsByRawPath.Values
.SelectMany(t => t.DependencyRefs)
.Distinct(StringComparer.Ordinal)
.ToArray();
}
// Reinit config-bind options — mirrors the factory's bind (case-insensitive, enum-string, trailing-comma
// tolerant) so an in-place config payload re-binds identically to the spawn payload.
private static readonly JsonSerializerOptions ReinitJsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
Converters = { new JsonStringEnumConverter() },
};
/// Re-bind the authored raw calc tags from a new merged DriverConfig JSON (an in-place
/// config payload). The ctor-fixed timeout is kept — RunTimeout is bound
/// at spawn (a change to it is out of this in-place path's scope). A parse failure logs and keeps the
/// current tags so a malformed delta can never blank the driver.
private CalculationDriverOptions ParseOptions(string driverConfigJson)
{
if (!string.IsNullOrWhiteSpace(driverConfigJson))
{
try
{
var dto = JsonSerializer.Deserialize(driverConfigJson, ReinitJsonOptions);
if (dto is not null)
{
return new CalculationDriverOptions
{
RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [],
RunTimeout = _options.RunTimeout,
Scripting = _options.Scripting,
};
}
}
catch (JsonException ex)
{
_logger.LogWarning(ex,
"Calculation {Driver}: reinit config parse failed; keeping the current tag table",
_driverInstanceId);
}
}
return new CalculationDriverOptions
{
RawTags = _options.RawTags, RunTimeout = _options.RunTimeout, Scripting = _options.Scripting,
};
}
/// Re-bind the tag table from — shared by
/// and (for a config with a body) so the two
/// adoption paths cannot drift. Timers must already be stopped.
private void AdoptConfig(string driverConfigJson)
{
var newOptions = ParseOptions(driverConfigJson);
lock (_lock)
{
// Queued evaluations were prepared against the OUTGOING tag table (old script source, old
// dependency set), so they are stale the moment the rebuild lands — drop them rather than
// publish one last pre-edit value after the delta was applied.
_pending.Clear();
RebuildTagTable(newOptions);
}
// Drop compiled scripts (IScriptCacheOwner contract — stops collectible ALCs accreting AND forces a
// recompile from each tag's NEW ScriptSource).
_evaluator.ClearCompiledScripts();
}
/// True when the supplied DriverConfig JSON carries a real body. The bootstrapper always passes a
/// populated document; unit tests pass "{}" to exercise lifecycle shape — those keep the
/// constructor-supplied tags (an empty document would otherwise bind to an EMPTY tag table).
private static bool HasConfigBody(string? driverConfigJson)
{
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
var trimmed = driverConfigJson.Trim();
return trimmed is not "{}" and not "[]";
}
/// Minimal reinit-bind DTO — the calc driver has no backend config, only the injected raw tags
/// (+ an optional eval-timeout, ignored on reinit since the evaluator is ctor-fixed). Mirrors the factory's
/// CalculationDriverConfigDto.
private sealed class ReinitConfigDto
{
/// The raw tag entries carried by the re-initialisation config.
public List? RawTags { get; init; }
/// The optional evaluation timeout in milliseconds; ignored on re-initialisation.
public int? RunTimeoutMs { get; init; }
}
// ---- IDriver ----
///
public string DriverInstanceId => _driverInstanceId;
///
public string DriverType => DriverTypeNames.Calculation;
// InitializeAsync takes a config document and the IDriver contract is that the driver serves it
// (the runtime's mid-connect adoption path was deleted) - so it adopts a config carrying a real
// body (the convention every other driver follows), and disposes any running timer scheduler before
// arming a fresh one (re-initialise without teardown must not leak). A "{}" / empty document keeps
// the constructor-supplied tags, as unit tests rely on.
///
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
StopTimers();
if (HasConfigBody(driverConfigJson)) AdoptConfig(driverConfigJson);
_running = true;
StartTimers();
_logger.LogInformation(
"Calculation {Driver}: initialized with {TagCount} calc tag(s), {DepCount} distinct dependency ref(s)",
_driverInstanceId, _tagsByRawPath.Count, _dependencyRefs.Count);
return Task.CompletedTask;
}
///
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
// An in-place config change: re-bind the new config and REBUILD the tag table + dependency-ref set, so
// added tags compute, removed tags stop publishing and edited scripts serve the new result. The runtime
// itself applies a DriverConfig change by respawning, where a fresh driver registers its
// new dependency refs on construction; this path keeps the IDriver contract honest for any caller.
StopTimers();
AdoptConfig(driverConfigJson);
// Re-arm timers off the rebuilt tag set.
_running = true;
StartTimers();
return Task.CompletedTask;
}
///
public Task ShutdownAsync(CancellationToken cancellationToken)
{
_running = false;
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;
}
// Current health, derived from the driver's own lifecycle state: Healthy while running; otherwise
// Unknown, retaining the last compute time. Health is never a hard-coded Healthy - a driver whose
// timers are stopped (before InitializeAsync, after ShutdownAsync, after Dispose) must not report
// Healthy, matching every other driver, whose _health field lands on Unknown at shutdown.
//
// There is no transport to be Degraded about - this driver computes from the dependency mux, so its
// only meaningful states are "running" and "not running". _running is the flag the compute paths
// themselves gate on, which is what makes it the honest input here.
///
public DriverHealth GetHealth() => _running
? new DriverHealth(DriverState.Healthy, _lastComputeUtc, null)
: new DriverHealth(DriverState.Unknown, _lastComputeUtc, null);
// ---- IDependencyConsumer ----
///
public IReadOnlyCollection DependencyRefs => _dependencyRefs;
///
public void OnDependencyValue(string rawPath, object? value, uint statusCode, DateTime timestampUtc)
{
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
// runs on the caller's thread, which for the live path is the Akka dispatcher thread of
// Runtime's DependencyConsumerMuxAdapter, and a script run is bounded only by RunTimeout.
// Everything under this lock is dictionary work; the script runs on the pump.
var queued = false;
foreach (var calcRawPath in dependents)
{
if (PrepareEvaluation(calcRawPath, timestampUtc) is not { } prepared) continue;
_pending.Enqueue(prepared);
queued = true;
}
if (queued) SchedulePump();
}
}
// ---- ISubscribable ----
///
public Task SubscribeAsync(
IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fullReferences);
var handle = new CalculationSubscriptionHandle($"calc-{Guid.NewGuid():N}");
// Initial-data callback (OPC UA convention): surface the current snapshot for each subscribed calc
// tag so a freshly-materialised node shows BadWaitingForInitialData until its first evaluation lands.
foreach (var reference in fullReferences)
{
var snapshot = ReadOne(reference);
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, reference, snapshot));
}
return Task.FromResult(handle);
}
///
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
=> Task.CompletedTask;
// ---- IReadable ----
///
public Task> ReadAsync(
IReadOnlyList fullReferences, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fullReferences);
var results = new DataValueSnapshot[fullReferences.Count];
for (var i = 0; i < fullReferences.Count; i++)
results[i] = ReadOne(fullReferences[i]);
return Task.FromResult>(results);
}
private DataValueSnapshot ReadOne(string reference)
{
var now = DateTime.UtcNow;
lock (_lock)
{
// Gated on HAS PUBLISHED, not on "has a value": a tag whose only publish was an inputs-Bad
// report has no value of its own, and answering BadWaitingForInitialData for it would tell a
// polled reader "never observed" while the subscriber on the same node had been told
// BadNoCommunication. Something HAS been observed about such a tag — that its input is dead.
if (!_stateByRawPath.TryGetValue(reference, out var st) || !st.HasPublished)
return new DataValueSnapshot(null, StatusBadWaitingForInitialData, null, now);
// Exactly what the subscriber was last told — including a propagated input sub-code. Deriving
// it from a two-state flag here would make a read disagree with the subscription over the
// same node.
var status = st.LastPublishedStatus;
return new DataValueSnapshot(st.LastValue, status, st.LastTimestampUtc, st.LastTimestampUtc);
}
}
// ---- evaluation ----
/// One prepared evaluation: the calc tag's definition, the dependency snapshot taken when its
/// trigger fired, and that trigger's timestamp. Built under by
/// and executed outside it, so the script itself needs no shared state.
/// The calc tag being evaluated.
/// Its declared reads' snapshots — value, status and timestamps — as of the trigger.
/// The trigger's timestamp, stamped on whatever is published.
/// The worst status among
/// (). Bad ⇒ the script is HELD and this status is published;
/// Good or Uncertain ⇒ the script runs and its result is stamped with it.
private sealed record PendingEvaluation(
CalculationTagDefinition Definition,
IReadOnlyDictionary Dependencies,
DateTime TimestampUtc,
uint WorstInputStatus);
///
/// Prepare one calc tag's evaluation: resolve the definition, apply the change-gate, and snapshot
/// the tag's declared dependency values. MUST be called under . Returns
/// when the tag must not evaluate — unknown, no resolved script source
/// (logged at init), or still warming up.
/// Change-gate (mini-design §4): publish nothing until every declared dep has arrived at
/// least once. This MUST hold BEFORE the evaluator runs, not just before the publish: a
/// partially-warmed-up snapshot is missing entries for the not-yet-arrived deps, and
/// VirtualTagContext.GetTag returns a Bad snapshot (.Value == null) for those — so the
/// common (double)ctx.GetTag("x").Value idiom throws NullReferenceException on every
/// warm-up tick for any multi-dependency script whose deps don't all arrive in the same tick.
/// Evaluating anyway (and discarding the result afterwards) used to turn ordinary warm-up into a
/// spurious "script threw" script-log Warning on every partial arrival, plus a wasted
/// Roslyn/compiled-cache invocation. Applying it here rather than on the pump is equivalent:
/// only ever grows, and the arrival that completes the set queues its own
/// evaluation.
/// Snapshotting the dependency values HERE — at trigger time — is what keeps the queued path
/// semantically identical to the old inline one: a burst of arrivals evaluates against the values
/// that triggered it rather than collapsing onto whatever happened to be current when the pump
/// caught up.
///
/// The calc tag to prepare.
/// The trigger's timestamp.
/// The prepared evaluation, or null when the tag must not evaluate.
private PendingEvaluation? PrepareEvaluation(string rawPath, DateTime timestampUtc)
{
if (!_tagsByRawPath.TryGetValue(rawPath, out var def)) return null;
if (string.IsNullOrWhiteSpace(def.ScriptSource)) return null; // unresolved script — never computes
if (!def.DependencyRefs.All(_arrivedDeps.Contains)) return null; // still warming up — do not evaluate
var deps = new Dictionary(StringComparer.Ordinal);
foreach (var dep in def.DependencyRefs)
if (_depValues.TryGetValue(dep, out var v)) deps[dep] = v;
// Worst-of-input, computed from the SAME snapshot the evaluation will use, so the status
// published can never disagree with the values it was derived from.
var worst = InputQuality.WorstOf(def.DependencyRefs.Select(r => deps.GetValueOrDefault(r)));
return new PendingEvaluation(def, deps, timestampUtc, worst);
}
/// Start the evaluation pump if it is not already running. MUST be called under
/// — the flag and the queue are checked together, which is what makes "an enqueue
/// either starts the pump or is seen by a pump that has not yet exited" hold.
private void SchedulePump()
{
if (_pumpScheduled) return;
_pumpScheduled = true;
_ = Task.Run(RunEvaluationPumpAsync);
}
/// 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).
private async Task RunEvaluationPumpAsync()
{
while (true)
{
PendingEvaluation next;
lock (_lock)
{
if (!_running) _pending.Clear();
if (_pending.Count == 0)
{
_pumpScheduled = false;
return;
}
next = _pending.Dequeue();
}
// _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.
if (!await TryEnterEvalGateAsync().ConfigureAwait(false)) return; // disposed
try
{
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();
}
}
}
/// 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).
private async Task EvaluateTagFromTimerAsync(string rawPath)
{
if (!_running) return;
PendingEvaluation? prepared;
lock (_lock) prepared = PrepareEvaluation(rawPath, DateTime.UtcNow);
if (prepared is null) return;
if (!await TryEnterEvalGateAsync().ConfigureAwait(false)) return; // disposed
try
{
await RunEvaluationAsync(prepared).ConfigureAwait(false);
}
finally
{
_evalGate.Release();
}
}
/// Acquire ; false once has disposed it (a pump
/// iteration or timer tick that raced the teardown simply stops).
private async Task 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;
}
/// Run one prepared evaluation and publish the outcome. MUST be called under
/// and NOT under : the script run is the whole point of the
/// split, and the script log + fan-out both invoke subscriber code.
/// The prepared evaluation.
private async Task RunEvaluationAsync(PendingEvaluation pending)
{
var def = pending.Definition;
// HOLD ON BAD (matching ScriptedAlarmEngine's AreInputsReady): a Bad input is not
// usable, so the script does NOT run — which removes the "(double)ctx.GetTag(x).Value threw"
// storm at its source rather than catching it downstream — and the tag reports the input's own
// sub-code. Uncertain IS usable: the script runs and the output inherits the Uncertain code.
if (!InputQuality.IsUsable(pending.WorstInputStatus))
{
(object? Value, uint StatusCode)? badPublish;
lock (_lock)
{
if (!_stateByRawPath.TryGetValue(def.RawPath, out var badState)) return;
badPublish = MarkInputsBad(badState, pending.WorstInputStatus, pending.TimestampUtc);
}
// No script log: an input dying is not a script fault, and the script log is the per-failure
// SCRIPT diagnostic. Here the status code is the diagnostic.
if (badPublish is { } bad) Publish(def.RawPath, bad.Value, bad.StatusCode, pending.TimestampUtc);
return;
}
// 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 = await _evaluator.EvaluateAsync(def.RawPath, def.ScriptSource, pending.Dependencies, _evalCts.Token)
.ConfigureAwait(false);
string? failureToLog = null;
(object? Value, uint StatusCode)? toPublish;
lock (_lock)
{
// A ReinitializeAsync can drop the tag while its script is running; its result is then stale
// by definition and there is no state left to record it against.
if (!_stateByRawPath.TryGetValue(def.RawPath, out var st)) return;
if (!result.Success)
{
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 (failureToLog is not null) PublishScriptLog(def, failureToLog);
if (toPublish is { } p) Publish(def.RawPath, p.Value, p.StatusCode, pending.TimestampUtc);
}
/// Record a successful evaluation. MUST be called under ; returns what to
/// publish, or null when the result is deduped.
/// The dedup key is (value, status), not value alone — generalising the
/// Bad-recovery bypass 02/S13 introduced. Recovery is a publish, never an assumption: the node is
/// currently carrying the previous status, so a result whose VALUE is unchanged but whose STATUS
/// differs — the shape of every recovery, since a device that comes back usually comes back holding
/// the same reading — must still land, or the node stays Bad forever.
/// The tag's published state.
/// The computed value.
/// The status to stamp: the worst input status (Good, or an Uncertain sub-code).
/// The trigger's timestamp.
/// The snapshot to publish, or null when deduped.
private (object? Value, uint StatusCode)? MarkEvaluationSucceeded(
TagState st, object? value, uint statusCode, DateTime timestampUtc)
{
if (st.HasPublished && st.LastPublishedStatus == statusCode && st.HasValue && ValuesEqual(st.LastValue, value))
return null;
st.HasValue = true;
st.LastValue = value;
st.HasPublished = true;
st.LastPublishedStatus = statusCode;
st.LastTimestampUtc = timestampUtc;
_lastComputeUtc = DateTime.UtcNow;
return (value, statusCode);
}
/// Value equality for the dedup: structural for arrays (R07:H8 — object.Equals is reference
/// equality for a double[], so an array-returning timer script republished every tick through
/// the dual-namespace fan-out and the historian), ordinary
/// otherwise.
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);
}
/// Record that this tag's INPUTS are Bad, so no evaluation ran. Publishes the
/// worst input's status — carrying the last known value — once per change of that status: a
/// repeat of the same Bad is silent, while BadNoCommunication arriving over an earlier
/// BadInternalError (or the reverse) is not masked, because the sub-code is what names which
/// failure the operator is looking at. MUST be called under .
/// The tag's published state.
/// The worst status among the tag's declared inputs.
/// The trigger's timestamp.
/// The snapshot to publish, or null when that status was already published.
private static (object? Value, uint StatusCode)? MarkInputsBad(
TagState st, uint worstInputStatus, DateTime timestampUtc)
{
if (st.HasPublished && st.LastPublishedStatus == worstInputStatus) return null;
st.HasPublished = true;
st.LastPublishedStatus = worstInputStatus;
st.LastTimestampUtc = timestampUtc;
// Carry the last-known value (null if none) with the input's Bad status.
return (st.HasValue ? st.LastValue : null, worstInputStatus);
}
/// Failure tail (mirrors VirtualTagActor 02/S13): the caller always emits the
/// per-failure script-log; this additionally returns a Bad snapshot — carrying the last-known value —
/// once per Good→Bad transition, and null on a repeat failure. MUST be called under
/// . Only reached once every declared dependency has arrived (the warm-up gate
/// returns before the evaluator ever runs), so there is no separate "inputs ready" condition here.
/// The tag's published state.
/// The trigger's timestamp.
/// The Bad snapshot to publish on a fresh Good→Bad transition, else null.
private static (object? Value, uint StatusCode)? MarkEvaluationFailed(TagState st, DateTime timestampUtc)
{
if (st.HasPublished && st.LastPublishedStatus == StatusBadInternalError) return null;
st.HasPublished = true;
st.LastPublishedStatus = StatusBadInternalError;
st.LastTimestampUtc = timestampUtc;
// Carry the last-known value (null if none) with Bad quality.
return (st.HasValue ? st.LastValue : null, StatusBadInternalError);
}
private void Publish(string rawPath, object? value, uint statusCode, DateTime timestampUtc)
{
var snapshot = new DataValueSnapshot(value, statusCode, timestampUtc, timestampUtc);
// Fired outside both locks, from the single evaluation worker — so publish order still follows
// evaluation order, and a subscriber can neither deadlock against the state lock nor re-enter the
// driver behind it (DriverInstanceActor marshals the event onto its own thread regardless).
OnDataChange?.Invoke(this, new DataChangeEventArgs(SharedHandle, rawPath, snapshot));
}
private void PublishScriptLog(CalculationTagDefinition def, string reason)
{
// Route through the script logger bound with ScriptId + the calc tag's RawPath (in the VirtualTagId
// slot the Script-log page attributes on). The ScriptRootLogger's ScriptLogTopicSink converts this
// Warning event into a ScriptLogEntry on the script-logs DPS topic — the driver needs no Akka access.
_scriptRoot.Logger
.ForContext(ScriptLoggerFactory.ScriptIdProperty, def.ScriptId)
.ForContext(ScriptLoggerFactory.VirtualTagIdProperty, def.RawPath)
.Warning("{Reason}", reason);
}
private void StartTimers()
{
var timed = _tagsByRawPath.Values.Where(t => t.TimerInterval is not null).ToArray();
if (timed.Length == 0) return;
_timers = new CalculationTimerScheduler(EvaluateTagFromTimerAsync, _logger);
_timers.Start(timed);
}
private void StopTimers()
{
_timers?.Dispose();
_timers = null;
}
/// Test/diagnostic accessor: number of timer ticks skipped for in-flight groups.
internal long TimerSkippedTickCount => _timers?.SkippedTickCount ?? 0;
/// Test/diagnostic accessor: sources currently remembered as failing to compile.
internal int CompileFailureCountForTest => _evaluator.CompileFailureCount;
/// Test/diagnostic accessor: compiled scripts currently held by the evaluator's cache.
internal int CompiledScriptCountForTest => _evaluator.CompiledScriptCount;
/// Test/diagnostic accessor: when no change-triggered evaluation is
/// queued or in flight. A test that used to assert straight after —
/// which evaluated inline — waits on this instead, so the assertion still runs against a settled
/// driver rather than a sleep.
internal bool IsEvaluationIdle
{
get { lock (_lock) return _pending.Count == 0 && !_pumpScheduled; }
}
// A single shared handle for host-pushed publishes (change + timer). SubscribeAsync mints its own
// per-call handle for the initial-data callback; steady-state publishes reuse this so OnDataChange
// always carries a non-null handle.
private static readonly CalculationSubscriptionHandle SharedHandle = new("calc-shared");
/// Stops the timers and disposes the script evaluator.
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_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;
/// Test hook — whether disposed the evaluation gate and its CTS.
internal bool EvalResourcesDisposedForTest => _evalResourcesDisposed;
/// Per-tag published state (guarded by ).
private sealed class TagState
{
public bool HasValue;
public object? LastValue;
/// Whether anything has been published for this tag yet. Distinct from
/// : an inputs-Bad publish carries no value of its own.
public bool HasPublished;
/// The status code last published for this tag. Replaces the old two-state
/// LastPublishedBad flag: the dedup key is (value, status), and a Bad status
/// now carries the failing INPUT's sub-code rather than a fixed BadInternalError.
public uint LastPublishedStatus;
public DateTime LastTimestampUtc;
/// 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.
public string? LastFailureReason;
}
private sealed record CalculationSubscriptionHandle(string DiagnosticId) : ISubscriptionHandle;
}