Files
lmxopcua/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLogRateLimiter.cs
T
Joseph Doherty ef2eebe6b6 fix(scripting): review minors: configured limit in summaries, dispose gate/CTS, validator, docs (fix round 1)
- ScriptLogRateLimiter.LimitPerWindow. The ScriptLogTopicSink and
  VirtualTagActor summaries now print the configured limit, not
  DefaultLimitPerWindow. Tests assert "limit N/window" for a non-default
  N.
- CalculationDriver.Dispose cancels, then takes _evalGate (bounded 2 s)
  and disposes the gate and _evalCts. The pump and timer ticks enter the
  gate through TryEnterEvalGateAsync, which stops cleanly once the
  driver is disposed. Test: Dispose with a 6 s runaway run in flight
  returns promptly, disposes both, and a late trigger is harmless.
- ScriptedAlarmEngine captures _shutdownToken once and disposes
  _shutdownCts in Dispose when every drained task has completed. A task
  stuck past the grace leaves the CTS to the GC. Lifecycle tests assert
  the disposal.
- DraftValidator: CalculationNoEffectiveTrigger is skipped for a
  change-triggered tag whose scriptId does not resolve. That case is
  already CalculationScriptMissing or CalculationScriptNotFound, and
  there are no reads to count. Both existing tests now assert the
  absence (red before the change).
- docs/VirtualTags.md: System.Threading is denied as a whole namespace,
  with an allow-list; the type list is now accurate (Unsafe,
  RuntimeHelpers, and the ForbiddenMembers on System.Type); the resource
  limits section covers the run guard and the allocation budget.
  docs/ScriptEditor.md: accurate deny-list and a new "Runtime safety"
  section covering timeout, refusal, quarantine after 3 timeouts,
  allocation budget, lifting, and rate-limit summaries that appear only
  on the source's next entry after a burst then quiet.

Refs #734

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 13:41:15 -04:00

127 lines
5.3 KiB
C#

namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
/// <summary>
/// Per-source fixed-window rate limit for the script-log fan-out: the first
/// <see cref="DefaultLimitPerWindow"/> entries a source emits in a <see cref="DefaultWindow"/> 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.
/// </summary>
/// <remarks>
/// Used by <see cref="ScriptLogTopicSink"/> (key = script + virtual-tag + alarm identity),
/// <see cref="ScriptLogCompanionSink"/> (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 <see cref="SweepEvery"/> admissions so a churning tag set cannot grow the table
/// without bound.
/// </remarks>
public sealed class ScriptLogRateLimiter
{
/// <summary>Entries admitted per source per window by default: 20.</summary>
public const int DefaultLimitPerWindow = 20;
/// <summary>The default window: one minute.</summary>
public static readonly TimeSpan DefaultWindow = TimeSpan.FromMinutes(1);
private const int SweepEvery = 256;
private readonly Dictionary<string, Source> _sources = new(StringComparer.Ordinal);
private readonly int _limit;
private readonly TimeSpan _window;
private readonly Func<DateTime> _clock;
private int _admitsSinceSweep;
/// <summary>Initializes a limiter.</summary>
/// <param name="limitPerWindow">Entries admitted per source per window; values below 1 are treated as 1.</param>
/// <param name="window">The fixed window; null ⇒ <see cref="DefaultWindow"/>.</param>
/// <param name="clock">UTC clock; null ⇒ <see cref="DateTime.UtcNow"/>.</param>
public ScriptLogRateLimiter(int limitPerWindow = DefaultLimitPerWindow, TimeSpan? window = null, Func<DateTime>? clock = null)
{
_limit = Math.Max(1, limitPerWindow);
_window = window is { } w && w > TimeSpan.Zero ? w : DefaultWindow;
_clock = clock ?? (static () => DateTime.UtcNow);
}
/// <summary>Entries admitted per source per window, as configured (after clamping to at least 1).</summary>
public int LimitPerWindow => _limit;
/// <summary>The window length.</summary>
public TimeSpan Window => _window;
/// <summary>
/// Ask whether <paramref name="sourceKey"/>'s next entry may be emitted. When the window has rolled
/// since the last call, <see cref="ScriptLogAdmission.SuppressedInPreviousWindow"/> 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.
/// </summary>
/// <param name="sourceKey">Identity of the emitting script instance.</param>
/// <returns>The admission decision.</returns>
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<string>? 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;
}
}
/// <summary>Result of <see cref="ScriptLogRateLimiter.Admit"/>.</summary>
/// <param name="Admitted">Whether the entry may be emitted.</param>
/// <param name="SuppressedInPreviousWindow">Entries dropped in the window that just closed — non-zero at
/// most once per window roll; the caller emits one summary line for them.</param>
public readonly record struct ScriptLogAdmission(bool Admitted, long SuppressedInPreviousWindow);