namespace ZB.MOM.WW.OtOpcUa.Core.Scripting; /// /// Per-source fixed-window rate limit for the script-log fan-out: the first /// entries a source emits in a are /// admitted, the rest are counted and dropped, and the count is handed back exactly once — on the first /// admission after the window rolls — so the caller can emit one summary line in the dropped entries' /// place. Nothing is silently lost (the operator sees "N suppressed"), and one chatty script cannot /// shed every other script's entries out of the bounded telemetry channels (drop-oldest) or flood the /// cluster bus / main log. /// /// /// Used by (key = script + virtual-tag + alarm identity), /// (key = script name) and the virtual-tag actor's per-failure /// log (one key per tag). Thread-safe; Serilog invokes a sink on whichever thread logged. Idle sources /// are swept every admissions so a churning tag set cannot grow the table /// without bound. /// public sealed class ScriptLogRateLimiter { /// Entries admitted per source per window by default: 20. public const int DefaultLimitPerWindow = 20; /// The default window: one minute. public static readonly TimeSpan DefaultWindow = TimeSpan.FromMinutes(1); private const int SweepEvery = 256; private readonly Dictionary _sources = new(StringComparer.Ordinal); private readonly int _limit; private readonly TimeSpan _window; private readonly Func _clock; private int _admitsSinceSweep; /// Initializes a limiter. /// Entries admitted per source per window; values below 1 are treated as 1. /// The fixed window; null ⇒ . /// UTC clock; null ⇒ . public ScriptLogRateLimiter(int limitPerWindow = DefaultLimitPerWindow, TimeSpan? window = null, Func? clock = null) { _limit = Math.Max(1, limitPerWindow); _window = window is { } w && w > TimeSpan.Zero ? w : DefaultWindow; _clock = clock ?? (static () => DateTime.UtcNow); } /// Entries admitted per source per window, as configured (after clamping to at least 1). public int LimitPerWindow => _limit; /// The window length. public TimeSpan Window => _window; /// /// Ask whether 's next entry may be emitted. When the window has rolled /// since the last call, carries the /// number of entries dropped in the window that just closed (zero otherwise) — the caller emits one /// summary for them BEFORE the admitted entry. /// /// Identity of the emitting script instance. /// The admission decision. public ScriptLogAdmission Admit(string sourceKey) { ArgumentNullException.ThrowIfNull(sourceKey); var now = _clock(); lock (_sources) { if (++_admitsSinceSweep >= SweepEvery) { _admitsSinceSweep = 0; Sweep(now); } if (!_sources.TryGetValue(sourceKey, out var source)) { source = new Source { WindowStart = now }; _sources[sourceKey] = source; } long summary = 0; if (now - source.WindowStart >= _window) { summary = source.Suppressed; source.WindowStart = now; source.Admitted = 0; source.Suppressed = 0; } source.LastSeen = now; if (source.Admitted < _limit) { source.Admitted++; return new ScriptLogAdmission(true, summary); } source.Suppressed++; return new ScriptLogAdmission(false, summary); } } private void Sweep(DateTime now) { var idle = now - (_window + _window); List? dead = null; foreach (var (key, source) in _sources) { // A source that still has a pending summary is kept: its next entry must report the count. if (source.LastSeen < idle && source.Suppressed == 0) (dead ??= []).Add(key); } if (dead is null) return; foreach (var key in dead) _sources.Remove(key); } private sealed class Source { public DateTime WindowStart; public DateTime LastSeen; public int Admitted; public long Suppressed; } } /// Result of . /// Whether the entry may be emitted. /// Entries dropped in the window that just closed — non-zero at /// most once per window roll; the caller emits one summary line for them. public readonly record struct ScriptLogAdmission(bool Admitted, long SuppressedInPreviousWindow);