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();
}