fix(runtime): restart-safe alarm host, mux last-value replay, VT log rate limit, Calculation remediation (E4)
- R02:H1 ScriptedAlarmHostActor gains a factory Props (Func<(upstream, engine)>) so every incarnation builds and owns its own engine; a supervision restart no longer re-creates the actor around a disposed engine. The legacy instance-taking Props stays source-compatible and is now restart-safe in the weaker sense (PreRestart marks the stop as a restart, PostStop then detaches instead of disposing). DriverHostActor still spawns through the legacy overload (not owned by this lane — F3). - R02:H3 OnAlarmCommand runs every engine op under a 10 s token linked to the actor's lifetime (DefaultCommandTimeout / Props commandTimeout) and logs a Warning on timeout instead of suspending the mailbox on a stalled store. - R02:L3 DependencyMuxTagUpstreamSource.RetainOnly(loaded dep-ref union), called after every successful load. - R02:P6 VirtualTagActor's per-failure ScriptLogEntry is rate-limited per tag (ScriptLogRateLimiter, 20/min + one summary); the Bad publish and the metric are untouched. - R07:H6 DependencyMuxActor retains the last (value, status, ts) per ever-registered ref (through unregisters) and replays them in one batch to a RegisterInterest(ReplayRetained: true) — VirtualTagActor, the Calculation mux adapter and the alarm host opt in; the historization recorder does not (a replay there would record a stale sample per deploy). CalculationDriver records inputs regardless of _running so a replay landing before InitializeAsync still satisfies the change-gate. - R07:P4 CalculationEvaluator negative-caches compile failures by source hash (one Roslyn attempt + one Warning per source until redeploy); the driver's script-log entry fires on the Good→Bad transition or a change of reason, matching the once-per-transition Bad publish. - R07:H8 array results dedup structurally. - R07:L2 ShutdownAsync drops the compiled scripts (collectible ALCs). - R07:U5 DraftValidator rule CalculationNoEffectiveTrigger: a tag with no timer whose change trigger is off or whose script has no literal ctx.GetTag read can never evaluate. Refs #734 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -338,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;
|
||||
}
|
||||
|
||||
@@ -363,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
|
||||
@@ -600,7 +609,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
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)
|
||||
{
|
||||
@@ -610,16 +619,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);
|
||||
}
|
||||
|
||||
@@ -638,7 +655,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;
|
||||
@@ -650,6 +667,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
|
||||
@@ -727,6 +755,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
|
||||
@@ -770,6 +804,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;
|
||||
|
||||
Reference in New Issue
Block a user