Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/TriggerEvalGate.cs
T

82 lines
4.1 KiB
C#

namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <summary>
/// WP3.1 (finding #4): the process-wide concurrency gate for trigger-expression evaluation.
///
/// <para>Before WP3.1, <c>ScriptActor</c> and <c>AlarmActor</c> evaluated their compiled
/// Expression triggers on the <see cref="ScriptExecutionScheduler"/> — the same bounded pool
/// of dedicated threads that runs script bodies. Eight scripts blocked in synchronous I/O
/// therefore stalled EVERY Expression trigger on the node behind them, for an unbounded time,
/// and the evaluation's own 2 s timeout did not even start until it was dequeued. An alarm
/// that should have raised in milliseconds simply never raised.</para>
///
/// <para>Trigger expressions are non-blocking <em>by construction</em>:
/// <see cref="TriggerExpressionGlobals"/> exposes only reads over an in-memory snapshot
/// dictionary, and the script trust gate has already denied I/O, network, threading, and
/// reflection long before the expression can deploy. They are short CPU-bound work — exactly
/// what the shared .NET thread pool is for. So they run there, and this semaphore is the only
/// thing bounding their fan-out. A second dedicated pool was considered and rejected: it would
/// add threads, gauges, and a second starvation surface for no isolation gain.</para>
///
/// <para>Per-trigger coalescing in the actors (one evaluation in flight, one pending) already
/// caps waiters at one per Expression trigger, so this gate's queue is bounded by trigger
/// count.</para>
///
/// Mirrors <see cref="ScriptExecutionScheduler.Shared"/>'s lazy-singleton plus injectable-seam
/// shape so tests can drive a gate of size 1 deterministically.
/// </summary>
public sealed class TriggerEvalGate : IDisposable
{
private readonly SemaphoreSlim _gate;
private static volatile TriggerEvalGate? _shared;
private static readonly object SharedLock = new();
/// <summary>Creates a gate admitting <paramref name="maxConcurrency"/> concurrent evaluations.</summary>
/// <param name="maxConcurrency">Maximum concurrent trigger-expression evaluations; values below 1 are clamped to 1.</param>
public TriggerEvalGate(int maxConcurrency)
{
MaxConcurrency = Math.Max(1, maxConcurrency);
_gate = new SemaphoreSlim(MaxConcurrency, MaxConcurrency);
}
/// <summary>The configured concurrency limit.</summary>
public int MaxConcurrency { get; }
/// <summary>Free permits right now; 0 means the gate is saturated and new evaluations will queue.</summary>
public int AvailablePermits => _gate.CurrentCount;
/// <summary>
/// The process-wide gate, used when no gate is injected. Lazily created from
/// <see cref="SiteRuntimeOptions.TriggerEvalMaxConcurrency"/>; the first caller wins.
/// </summary>
/// <param name="options">Site runtime options supplying the concurrency limit.</param>
/// <returns>The shared gate instance.</returns>
public static TriggerEvalGate Shared(SiteRuntimeOptions options)
{
var existing = _shared;
if (existing is not null) return existing;
lock (SharedLock)
{
return _shared ??= new TriggerEvalGate(options.TriggerEvalMaxConcurrency);
}
}
/// <summary>
/// Waits for a permit. The caller passes the evaluation's own deadline token, which was
/// armed at ENQUEUE — so time spent queueing here burns the same budget the evaluation
/// itself would, and a saturated gate produces a timely cancellation (treated as
/// <see langword="false"/> by both actors) instead of an unbounded stall.
/// </summary>
/// <param name="cancellationToken">The evaluation's deadline token.</param>
/// <returns>A task that completes when a permit is acquired.</returns>
public Task WaitAsync(CancellationToken cancellationToken) => _gate.WaitAsync(cancellationToken);
/// <summary>Returns a permit. Must be called exactly once per successful <see cref="WaitAsync"/>.</summary>
public void Release() => _gate.Release();
/// <inheritdoc />
public void Dispose() => _gate.Dispose();
}