namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; /// /// WP3.1 (finding #4): the process-wide concurrency gate for trigger-expression evaluation. /// /// Before WP3.1, ScriptActor and AlarmActor evaluated their compiled /// Expression triggers on the — 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. /// /// Trigger expressions are non-blocking by construction: /// 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. /// /// 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. /// /// Mirrors 's lazy-singleton plus injectable-seam /// shape so tests can drive a gate of size 1 deterministically. /// public sealed class TriggerEvalGate : IDisposable { private readonly SemaphoreSlim _gate; private static volatile TriggerEvalGate? _shared; private static readonly object SharedLock = new(); /// Creates a gate admitting concurrent evaluations. /// Maximum concurrent trigger-expression evaluations; values below 1 are clamped to 1. public TriggerEvalGate(int maxConcurrency) { MaxConcurrency = Math.Max(1, maxConcurrency); _gate = new SemaphoreSlim(MaxConcurrency, MaxConcurrency); } /// The configured concurrency limit. public int MaxConcurrency { get; } /// Free permits right now; 0 means the gate is saturated and new evaluations will queue. public int AvailablePermits => _gate.CurrentCount; /// /// The process-wide gate, used when no gate is injected. Lazily created from /// ; the first caller wins. /// /// Site runtime options supplying the concurrency limit. /// The shared gate instance. public static TriggerEvalGate Shared(SiteRuntimeOptions options) { var existing = _shared; if (existing is not null) return existing; lock (SharedLock) { return _shared ??= new TriggerEvalGate(options.TriggerEvalMaxConcurrency); } } /// /// 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 /// by both actors) instead of an unbounded stall. /// /// The evaluation's deadline token. /// A task that completes when a permit is acquired. public Task WaitAsync(CancellationToken cancellationToken) => _gate.WaitAsync(cancellationToken); /// Returns a permit. Must be called exactly once per successful . public void Release() => _gate.Release(); /// public void Dispose() => _gate.Dispose(); }