docs(xmldoc): fill missing XML docs + strip tracking-ID comments across src
v2-ci / build (push) Failing after 41s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped

Adds <summary>/<param>/<returns>/<inheritdoc> where missing and removes
project bookkeeping IDs (task/tracking refs) from shipped code comments,
so the docs read cleanly and CommentChecker is quiet except for known
false positives (PLC/protocol terms, event/IEqualityComparer inheritdoc).
Doc/comment-only; no logic changed; solution builds clean.
This commit is contained in:
Joseph Doherty
2026-07-07 12:38:39 -04:00
parent 384dbd7d36
commit 9cad9ed0fc
375 changed files with 1899 additions and 2493 deletions
@@ -5,12 +5,12 @@ namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
/// <summary>
/// Persistent per-alarm state tracked by the Part 9 state machine. Every field
/// carried here either participates in the state machine or contributes to the
/// audit trail required by Phase 7 plan decision #14 (GxP / 21 CFR Part 11).
/// audit trail required for GxP / 21 CFR Part 11 compliance.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="Active"/> is re-derived from the predicate at startup per Phase 7
/// decision #14 — the engine runs every alarm's predicate against current tag
/// <see cref="Active"/> is re-derived from the predicate at startup — the engine
/// runs every alarm's predicate against current tag
/// values at <c>Load</c>, overriding whatever Active state is in the store.
/// Every other state field persists verbatim across server restarts so
/// operators don't re-ack active alarms after an outage + shelved alarms stay
@@ -22,7 +22,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
/// entries. The runtime type is <see cref="ImmutableList{AlarmComment}"/> so
/// each append is O(log n) rather than the O(n) copy a plain
/// <c>IReadOnlyList&lt;AlarmComment&gt;</c> would force on every audit-producing
/// transition. (Core.ScriptedAlarms-008)
/// transition.
/// </para>
/// </remarks>
public sealed record AlarmConditionState(
@@ -3,8 +3,8 @@ using System.Collections.Concurrent;
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
/// <summary>
/// Persistence for <see cref="AlarmConditionState"/> across server restarts. Phase 7
/// plan decision #14: operator-supplied state (EnabledState / AckedState /
/// Persistence for <see cref="AlarmConditionState"/> across server restarts.
/// Operator-supplied state (EnabledState / AckedState /
/// ConfirmedState / ShelvingState + audit trail) persists; ActiveState is
/// recomputed from the live predicate on startup so operators never re-ack.
/// </summary>
@@ -4,8 +4,8 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
/// <summary>
/// Per Phase 7 plan decision #13, alarm messages are static-with-substitution
/// templates. The engine resolves <c>{TagPath}</c> tokens at event emission time
/// Alarm messages are static-with-substitution templates. The engine resolves
/// <c>{TagPath}</c> tokens at event emission time
/// against current tag values; unresolvable tokens become <c>{?}</c> so the event
/// still fires but the operator sees where the reference broke.
/// </summary>
@@ -41,7 +41,6 @@ public static class MessageTemplate
/// inspect, but the operator-facing message must make doubt explicit rather
/// than substituting a value an operator might act on. See the
/// "Input-quality policy" section in <c>docs/ScriptedAlarms.md</c>.
/// (Core.ScriptedAlarms-010)
/// </remarks>
/// <param name="template">The template string with {path} tokens.</param>
/// <param name="resolveTag">A function to resolve tag values by path.</param>
@@ -340,7 +340,6 @@ public static class Part9StateMachine
/// no operator intent recorded — e.g. a predicate re-evaluation that
/// confirms the existing active state) leave <see cref="NoOpReason"/>
/// null because there is nothing to surface to an operator.
/// (Core.ScriptedAlarms-011)
/// </para>
/// </remarks>
public sealed record TransitionResult(AlarmConditionState State, EmissionKind Emission, string? NoOpReason = null)
@@ -17,15 +17,15 @@ namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
/// </param>
/// <param name="AlarmName">Human-readable alarm name — used in the browse tree + Admin UI.</param>
/// <param name="Kind">Concrete OPC UA Part 9 subtype the alarm materializes as.</param>
/// <param name="Severity">Static severity per Phase 7 plan decision #13; not currently computed by the predicate.</param>
/// <param name="Severity">Static severity; not currently computed by the predicate.</param>
/// <param name="MessageTemplate">
/// Message text with <c>{TagPath}</c> tokens resolved at event-emission time per
/// Phase 7 plan decision #13. Unresolvable tokens emit <c>{?}</c> + a structured
/// Message text with <c>{TagPath}</c> tokens resolved at event-emission time.
/// Unresolvable tokens emit <c>{?}</c> + a structured
/// error so operators can spot stale references.
/// </param>
/// <param name="PredicateScriptSource">
/// Roslyn C# script returning <c>bool</c>. <c>true</c> = alarm condition currently holds (active);
/// <c>false</c> = condition has cleared. Same sandbox rules as virtual tags per Phase 7 decision #6.
/// <c>false</c> = condition has cleared. Same sandbox rules as virtual tags.
/// </param>
/// <param name="HistorizeToAveva">
/// When true, every transition emission of this alarm flows to the Historian alarm
@@ -46,7 +46,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
// A plain Dictionary read concurrent with a writer's entry reassignment can
// throw or return torn state; ConcurrentDictionary makes entry assignment and
// snapshot enumeration safe. The only write shapes are indexer-set and Clear,
// both of which ConcurrentDictionary supports atomically. (Core.ScriptedAlarms-001)
// both of which ConcurrentDictionary supports atomically.
private readonly ConcurrentDictionary<string, AlarmState> _alarms = new(StringComparer.Ordinal);
/// <summary>
@@ -58,7 +58,6 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// runs under <see cref="_evalGate"/>, which serialises every evaluation:
/// two threads can never observe the same scratch in a half-refilled state.
/// Cleared in <see cref="LoadAsync"/> alongside <see cref="_alarms"/>.
/// (Core.ScriptedAlarms-009)
/// </summary>
private readonly ConcurrentDictionary<string, AlarmScratch> _scratchByAlarmId =
new(StringComparer.Ordinal);
@@ -67,11 +66,11 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// Compile cache for every alarm predicate. Routes <see cref="LoadAsync"/>'s
/// <see cref="ScriptEvaluator{TContext, TResult}.Compile"/> calls through the
/// cache so the collectible <see cref="System.Runtime.Loader.AssemblyLoadContext"/>
/// each compile produces is actually disposed on the publish-replace path
/// (Core.Scripting-016): the cache's <see cref="CompiledScriptCache{TContext, TResult}.Clear"/>
/// each compile produces is actually disposed on the publish-replace path:
/// the cache's <see cref="CompiledScriptCache{TContext, TResult}.Clear"/>
/// disposes every materialised evaluator before dropping its dictionary entry,
/// so a config-publish releases the prior generation's ALCs and the per-publish
/// accretion the Core.Scripting-008 fix targeted is actually freed in production.
/// accretion the prior fix targeted is actually freed in production.
/// Pre-fix the engine called <c>ScriptEvaluator.Compile</c> directly, which left
/// the ALCs rooted until the process exited — defeating -008 on the real path.
/// </summary>
@@ -79,9 +78,9 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// <summary>
/// Test-only diagnostic: returns the per-alarm scratch read-cache dictionary
/// if one has been allocated, else null. Used by Core.ScriptedAlarms-009
/// regression tests to assert the scratch is reused across evaluations
/// (two reads return the same instance).
/// if one has been allocated, else null. Used by regression tests to assert
/// the scratch is reused across evaluations (two reads return the same
/// instance).
/// </summary>
/// <remarks>
/// <b>Synchronization:</b> the returned <see cref="IReadOnlyDictionary{TKey, TValue}"/>
@@ -94,9 +93,10 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// comparisons (e.g. asserting the same instance is reused across calls),
/// and single-key reads against an engine that has quiesced after a
/// deterministic upstream push. Anything more involved should snapshot a
/// copy under the gate. (Core.ScriptedAlarms-013.)
/// copy under the gate.
/// </remarks>
/// <param name="alarmId">The alarm identifier to look up.</param>
/// <returns>The alarm's read-cache dictionary if one has been allocated; otherwise null.</returns>
internal IReadOnlyDictionary<string, DataValueSnapshot>? TryGetScratchReadCacheForTest(string alarmId)
=> _scratchByAlarmId.TryGetValue(alarmId, out var s) ? s.ReadCache : null;
@@ -110,9 +110,9 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// read-cache as <see cref="TryGetScratchReadCacheForTest"/> — the same
/// "don't iterate during an in-flight evaluation" caveat applies. Safe
/// for reference-identity assertions on a quiesced engine.
/// (Core.ScriptedAlarms-013.)
/// </remarks>
/// <param name="alarmId">The alarm identifier to look up.</param>
/// <returns>The alarm's predicate context if one has been allocated; otherwise null.</returns>
internal AlarmPredicateContext? TryGetScratchContextForTest(string alarmId)
=> _scratchByAlarmId.TryGetValue(alarmId, out var s) ? s.Context : null;
private readonly ConcurrentDictionary<string, DataValueSnapshot> _valueCache
@@ -131,7 +131,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
// these so a re-evaluation in flight when shutdown begins finishes its
// SaveAsync before the engine returns control to the caller. The HashSet is
// accessed under its own lock — never under _evalGate — so registration /
// unregistration cannot deadlock against the gate. (Core.ScriptedAlarms-006)
// unregistration cannot deadlock against the gate.
private readonly HashSet<Task> _inFlight = [];
private readonly object _inFlightLock = new();
@@ -171,10 +171,11 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// compile failures into one <see cref="InvalidOperationException"/>, subscribes
/// to upstream input tags, seeds the value cache, loads persisted state from
/// the store (falling back to Fresh for first-load alarms), and recomputes
/// ActiveState per Phase 7 plan decision #14 (startup recovery).
/// ActiveState from the current predicate (startup recovery).
/// </summary>
/// <param name="definitions">The alarm definitions to load.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that completes once every definition has been loaded and state restored.</returns>
public async Task LoadAsync(IReadOnlyList<ScriptedAlarmDefinition> definitions, CancellationToken ct)
{
if (_disposed) throw new ObjectDisposedException(nameof(ScriptedAlarmEngine));
@@ -191,7 +192,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
// assignment at the bottom of the try block. Without this, the old timer
// keeps firing against the partially-cleared _alarms until Dispose() is
// eventually called — not a permanent leak, but an unexpected side effect
// during the window. (Core.ScriptedAlarms-015)
// during the window.
_shelvingTimer?.Dispose();
_shelvingTimer = null;
@@ -200,11 +201,11 @@ public sealed class ScriptedAlarmEngine : IDisposable
_alarmsReferencing.Clear();
// Drop the prior generation's per-alarm scratch buffers — definitions may
// have changed (different Inputs, different Logger), so any reuse would be
// unsafe. (Core.ScriptedAlarms-009)
// unsafe.
_scratchByAlarmId.Clear();
// Dispose every compiled-predicate ALC from the prior generation BEFORE we
// recompile this one. Skipping this is what made Core.Scripting-008 a
// no-op in production. (Core.Scripting-016)
// recompile this one. Skipping this is what made the earlier fix a
// no-op in production.
_compileCache.Clear();
var compileFailures = new List<string>();
@@ -222,7 +223,6 @@ public sealed class ScriptedAlarmEngine : IDisposable
// Route through CompiledScriptCache so the emitted assembly's
// collectible ALC participates in publish-replace cleanup.
// (Core.Scripting-016)
var evaluator = _compileCache.GetOrCompile(def.PredicateScriptSource);
var timed = new TimedScriptEvaluator<AlarmPredicateContext, bool>(evaluator, _scriptTimeout);
var logger = _loggerFactory.Create(def.AlarmId);
@@ -256,21 +256,20 @@ public sealed class ScriptedAlarmEngine : IDisposable
// Seed the value cache with current tag values before subscribing. The
// ReadTag calls happen first so that the initial predicate evaluation below
// (startup recovery, decision #14) uses a consistent snapshot.
// (startup recovery) uses a consistent snapshot.
// Subscriptions are established AFTER _loaded = true so that any synchronous
// initial-push an ITagUpstreamSource delivers from inside SubscribeTag arrives
// when _alarms is fully initialised. Before _loaded = true, a synchronous push
// would race the in-progress state restore and could overwrite the carefully
// seeded cache with a push that has no defined ordering relative to ReadTag.
// (Core.ScriptedAlarms-004)
foreach (var path in _alarmsReferencing.Keys)
_valueCache[path] = _upstream.ReadTag(path);
// Restore persisted state, falling back to Fresh where nothing was saved,
// then re-derive ActiveState from the current predicate per decision #14.
// then re-derive ActiveState from the current predicate.
// Any predicate emissions queue into `pending` and fire after the gate
// is released — so a startup-recovery activation event can call back into
// the engine without deadlocking. (Core.ScriptedAlarms-003)
// the engine without deadlocking.
foreach (var (alarmId, state) in _alarms)
{
var persisted = await _store.LoadAsync(alarmId, ct).ConfigureAwait(false);
@@ -294,8 +293,8 @@ public sealed class ScriptedAlarmEngine : IDisposable
// Start the shelving-check timer — ticks every 5s, expires any timed shelves
// that have passed their UnshelveAtUtc. The prior timer was already disposed
// at the START of this try block (Core.ScriptedAlarms-015), so _shelvingTimer
// is null here; no double-dispose risk. (Core.ScriptedAlarms-002, -015)
// at the START of this try block, so _shelvingTimer
// is null here; no double-dispose risk.
_shelvingTimer = new Timer(_ => RunShelvingCheck(),
null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
}
@@ -305,7 +304,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
}
// Fire any emissions collected during startup recovery OUTSIDE the gate so
// subscribers can re-enter the engine safely. (Core.ScriptedAlarms-003)
// subscribers can re-enter the engine safely.
foreach (var evt in pending) FireEvent(evt);
}
@@ -314,10 +313,12 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// unknown alarm. Mainly used for diagnostics + the Admin UI status page.
/// </summary>
/// <param name="alarmId">The alarm identifier.</param>
/// <returns>The alarm's current condition state, or null if the alarm id is unknown.</returns>
public AlarmConditionState? GetState(string alarmId)
=> _alarms.TryGetValue(alarmId, out var s) ? s.Condition : null;
/// <summary>Gets the current persisted state for all loaded alarms.</summary>
/// <returns>The current condition state of every loaded alarm.</returns>
public IReadOnlyCollection<AlarmConditionState> GetAllStates()
=> _alarms.Values.Select(a => a.Condition).ToArray();
@@ -326,6 +327,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// <param name="user">The user performing the acknowledgment.</param>
/// <param name="comment">An optional comment to attach to the acknowledgment.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that completes once the acknowledgment has been applied and persisted.</returns>
public Task AcknowledgeAsync(string alarmId, string user, string? comment, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyAcknowledge(cur, user, comment, _clock()));
@@ -334,6 +336,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// <param name="user">The user performing the confirmation.</param>
/// <param name="comment">An optional comment to attach to the confirmation.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that completes once the confirmation has been applied and persisted.</returns>
public Task ConfirmAsync(string alarmId, string user, string? comment, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyConfirm(cur, user, comment, _clock()));
@@ -341,6 +344,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// <param name="alarmId">The alarm identifier.</param>
/// <param name="user">The user performing the shelve operation.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that completes once the shelve has been applied and persisted.</returns>
public Task OneShotShelveAsync(string alarmId, string user, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyOneShotShelve(cur, user, _clock()));
@@ -349,6 +353,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// <param name="user">The user performing the shelve operation.</param>
/// <param name="unshelveAtUtc">The UTC time at which the shelve will automatically expire.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that completes once the timed shelve has been applied and persisted.</returns>
public Task TimedShelveAsync(string alarmId, string user, DateTime unshelveAtUtc, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyTimedShelve(cur, user, unshelveAtUtc, _clock()));
@@ -356,6 +361,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// <param name="alarmId">The alarm identifier.</param>
/// <param name="user">The user performing the unshelve operation.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that completes once the unshelve has been applied and persisted.</returns>
public Task UnshelveAsync(string alarmId, string user, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyUnshelve(cur, user, _clock()));
@@ -363,6 +369,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// <param name="alarmId">The alarm identifier.</param>
/// <param name="user">The user performing the enable operation.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that completes once the enable has been applied and persisted.</returns>
public Task EnableAsync(string alarmId, string user, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyEnable(cur, user, _clock()));
@@ -370,6 +377,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// <param name="alarmId">The alarm identifier.</param>
/// <param name="user">The user performing the disable operation.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that completes once the disable has been applied and persisted.</returns>
public Task DisableAsync(string alarmId, string user, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyDisable(cur, user, _clock()));
@@ -378,6 +386,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// <param name="user">The user adding the comment.</param>
/// <param name="text">The comment text.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that completes once the comment has been recorded and persisted.</returns>
public Task AddCommentAsync(string alarmId, string user, string text, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyAddComment(cur, user, text, _clock()));
@@ -395,14 +404,13 @@ public sealed class ScriptedAlarmEngine : IDisposable
// Persist BEFORE updating in-memory so a store failure leaves both
// in-memory and persisted at the prior state rather than diverging.
// If SaveAsync throws the in-memory _alarms entry stays unchanged and
// the exception propagates to the caller. (Core.ScriptedAlarms-007)
// the exception propagates to the caller.
await _store.SaveAsync(result.State, ct).ConfigureAwait(false);
_alarms[alarmId] = state with { Condition = result.State };
// Build the emission event under the gate (it captures a coherent
// snapshot of state + message-template values) but defer the actual
// OnEvent dispatch until after Release() so a slow subscriber or a
// subscriber that re-enters the engine doesn't block / deadlock.
// (Core.ScriptedAlarms-003)
if (result.Emission != EmissionKind.None)
pending = BuildEmission(state, result.State, result.Emission);
else if (result.NoOpReason is { } reason)
@@ -411,7 +419,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
// disabled-alarm no-ops + idempotent ack/confirm/shelve/unshelve
// calls. We surface them at debug so they're available when
// investigating "why didn't my ack take effect?" without spamming
// the main info log. (Core.ScriptedAlarms-011)
// the main info log.
state.Logger.Debug("Alarm {AlarmId} no-op transition: {Reason}", alarmId, reason);
}
}
@@ -427,7 +435,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// Upstream-change callback. Updates the value cache + enqueues predicate
/// re-evaluation for every alarm referencing the changed path. Fire-and-forget
/// so driver-side dispatch isn't blocked; the background task is tracked so
/// <see cref="Dispose"/> can drain it. (Core.ScriptedAlarms-006)
/// <see cref="Dispose"/> can drain it.
/// </summary>
/// <param name="path">The upstream tag path that changed.</param>
/// <param name="value">The new data value snapshot.</param>
@@ -452,7 +460,6 @@ public sealed class ScriptedAlarmEngine : IDisposable
// Re-check after acquiring the gate: a Dispose() call may have
// completed between our _evalGate.WaitAsync and here. Writing to a
// disposing store or mutating _alarms after clear is unsafe.
// (Core.ScriptedAlarms-005)
if (_disposed) return;
foreach (var id in alarmIds)
@@ -463,7 +470,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
if (!ReferenceEquals(newState, state.Condition))
{
// Persist before updating in-memory so a store failure leaves
// both sides at the prior state. (Core.ScriptedAlarms-007)
// both sides at the prior state.
await _store.SaveAsync(newState, ct).ConfigureAwait(false);
_alarms[id] = state with { Condition = newState };
}
@@ -477,7 +484,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
return;
}
// Fire emissions OUTSIDE _evalGate so subscriber callbacks can re-enter
// the engine without deadlocking. (Core.ScriptedAlarms-003)
// the engine without deadlocking.
foreach (var evt in pending) FireEvent(evt);
}
@@ -486,14 +493,13 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// Returns the new condition state. If the transition produces an emission,
/// appends it to <paramref name="pendingEmissions"/> so the caller can fire
/// them after releasing <c>_evalGate</c> — keeping subscriber callbacks
/// outside the gate. (Core.ScriptedAlarms-003)
/// outside the gate.
/// </summary>
/// <remarks>
/// Every caller (LoadAsync and ReevaluateAsync) owns a <c>pending</c> list and
/// passes it here; emissions are always deferred to after the gate is released.
/// The parameter is required (non-nullable) to make this contract explicit and
/// prevent a future caller from accidentally firing events under the gate.
/// (Core.ScriptedAlarms-014)
/// </remarks>
private async Task<AlarmConditionState> EvaluatePredicateToStateAsync(
AlarmState state, AlarmConditionState seed, DateTime nowUtc, CancellationToken ct,
@@ -501,7 +507,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
{
// Look up (or lazily allocate) the per-alarm scratch and refill its read cache
// in place. The dictionary + context survive across evaluations so the hot path
// no longer allocates per upstream tag change. (Core.ScriptedAlarms-009)
// no longer allocates per upstream tag change.
var scratch = _scratchByAlarmId.GetOrAdd(
state.Definition.AlarmId,
_ => new AlarmScratch(state.Inputs, state.Logger, _clock));
@@ -552,7 +558,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// first upstream push hasn't arrived yet. The dictionary is cleared and
/// repopulated under <c>_evalGate</c> so no concurrent reader can observe
/// a partial state. Replaces the old <c>BuildReadCache</c> which allocated a
/// fresh dictionary every call (Core.ScriptedAlarms-009).
/// fresh dictionary every call.
/// </summary>
private void RefillReadCache(
Dictionary<string, DataValueSnapshot> cache, IReadOnlySet<string> inputs)
@@ -591,7 +597,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// <c>_evalGate</c> so the message-template resolution uses a coherent
/// value-cache snapshot. The actual <see cref="OnEvent"/> dispatch is
/// done by <see cref="FireEvent(ScriptedAlarmEvent)"/> AFTER the gate is
/// released. (Core.ScriptedAlarms-003)
/// released.
/// </summary>
private ScriptedAlarmEvent? BuildEmission(AlarmState state, AlarmConditionState condition, EmissionKind kind)
{
@@ -632,7 +638,6 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// block the gate for every other engine operation, and a subscriber
/// that re-enters the engine (e.g. calls AcknowledgeAsync) would
/// deadlock against the non-reentrant SemaphoreSlim.
/// (Core.ScriptedAlarms-003)
/// </summary>
private void FireEvent(ScriptedAlarmEvent evt)
{
@@ -656,7 +661,6 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// <summary>
/// Register a fire-and-forget task so <see cref="Dispose"/> can await it.
/// The task removes itself from the set on completion via a continuation.
/// (Core.ScriptedAlarms-006)
/// </summary>
private void TrackBackgroundTask(Task task)
{
@@ -673,7 +677,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// <summary>
/// Test hook — triggers a shelving check synchronously without waiting for
/// the 5-second timer. Allows tests that inject a controllable clock to advance
/// time and immediately drive timed-shelve expiry. (Core.ScriptedAlarms-012)
/// time and immediately drive timed-shelve expiry.
/// </summary>
internal void RunShelvingCheckForTest() => RunShelvingCheck();
@@ -689,7 +693,6 @@ public sealed class ScriptedAlarmEngine : IDisposable
// running callbacks, so a shelving-check callback that passed the _disposed
// check in RunShelvingCheck can arrive here after Dispose() has returned.
// Mutating _alarms or saving to a disposed store here is unsafe.
// (Core.ScriptedAlarms-005)
if (_disposed) return;
var now = _clock();
@@ -700,7 +703,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
if (!ReferenceEquals(result.State, state.Condition))
{
// Persist before updating in-memory so a store failure leaves
// both sides at the prior state. (Core.ScriptedAlarms-007)
// both sides at the prior state.
await _store.SaveAsync(result.State, ct).ConfigureAwait(false);
_alarms[id] = state with { Condition = result.State };
if (result.Emission != EmissionKind.None)
@@ -718,7 +721,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
_engineLogger.Warning(ex, "ScriptedAlarmEngine shelving-check failed");
return;
}
// Fire emissions OUTSIDE _evalGate. (Core.ScriptedAlarms-003)
// Fire emissions OUTSIDE _evalGate.
foreach (var evt in pending) FireEvent(evt);
}
@@ -751,7 +754,6 @@ public sealed class ScriptedAlarmEngine : IDisposable
// flight can outlive the engine and write to a (possibly disposed) store
// after Dispose() has returned. The tasks re-check _disposed after
// acquiring the gate and bail out, but the await still has to complete.
// (Core.ScriptedAlarms-006)
Task[] toAwait;
lock (_inFlightLock) { toAwait = [.. _inFlight]; }
if (toAwait.Length > 0)
@@ -769,20 +771,19 @@ public sealed class ScriptedAlarmEngine : IDisposable
// Safe to clear here: the Task.WhenAll drain above guaranteed no
// ReevaluateAsync / ShelvingCheckAsync is mid-flight, and _disposed=true
// prevents new background work from being queued (OnUpstreamChange bails on
// line 334). Pre-Core.Scripting-016 the comment said "Do NOT clear _alarms",
// line 334). Previously the comment said "Do NOT clear _alarms",
// but that was when the engine called ScriptEvaluator.Compile directly and
// held the script ALCs through _alarms→AlarmState→TimedScriptEvaluator
// forever — leaving them rooted defeated the -008 collectible-ALC unload.
// forever — leaving them rooted defeated the collectible-ALC unload.
// Clearing now drops the delegate references so the cache's Dispose call
// below can actually unload the emitted assemblies. (Core.ScriptedAlarms-005
// re-evaluated under -016.)
// below can actually unload the emitted assemblies.
_alarms.Clear();
_alarmsReferencing.Clear();
_scratchByAlarmId.Clear();
// Dispose every compiled-predicate ALC so the engine's shutdown actually
// releases the emitted assemblies. The drain above ensures no evaluator is
// mid-call; CompiledScriptCache.Dispose internally guards against use-after-
// dispose. (Core.Scripting-016)
// dispose.
_compileCache.Dispose();
}
@@ -800,7 +801,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// cleared and refilled in <see cref="ScriptedAlarmEngine.RefillReadCache"/> on
/// each call. <see cref="Context"/> wraps that dictionary by reference, so a
/// refilled <see cref="ReadCache"/> is what the predicate's
/// <c>ctx.GetTag(path)</c> calls observe. (Core.ScriptedAlarms-009)
/// <c>ctx.GetTag(path)</c> calls observe.
/// </summary>
/// <remarks>
/// Reuse is safe because <see cref="ScriptedAlarmEngine"/> serialises every
@@ -124,7 +124,7 @@ public sealed class ScriptedAlarmSource : IAlarmSource, IDisposable
/// <summary>Initializes a new instance of the <see cref="SubscriptionHandle"/> class.</summary>
/// <param name="id">The diagnostic ID for this subscription handle.</param>
public SubscriptionHandle(string id) { DiagnosticId = id; }
/// <summary>Gets the diagnostic ID that uniquely identifies this subscription.</summary>
/// <inheritdoc />
public string DiagnosticId { get; }
}