Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptLogRateLimiterTests.cs
T
Joseph Doherty 6cbf763719 fix(alarms): engine cache prune + bounded dispose, source Warning, cached dead-letter depth, script-log rate limit (E3)
- R05:1/12 ScriptedAlarmEngine: _valueCache is cleared in LoadAsync (it is
  re-seeded for the referenced paths) so retired tags no longer accrete across
  deploys; OnUpstreamChange no longer writes the cache after Dispose.
- R05:3 ScriptedAlarmEngine.Dispose drains in-flight work for at most 5 s
  (DisposeDrainTimeout), then cancels it through a shutdown token every
  DrainDirtyAsync/ShelvingCheckAsync now runs on, logs a Warning and gives
  one second of grace — a stalled IAlarmStateStore.SaveAsync can no longer
  hang host shutdown. A save that completes inside the bound is still drained
  (positive control test).
- R05:6 ScriptedAlarmSource logs a throwing OnAlarmEvent subscriber at
  Warning (naming the alarm) instead of swallowing it.
- R05:7 LocalDbStoreAndForwardSink keeps a cached dead-letter counter in step
  with every dead-letter/revive/purge path and resyncs it with the queued
  counter on the same cadence; GetStatus no longer runs a COUNT(*) scan.
- R05:9 ScriptLogRateLimiter (20 per source per minute, fixed window, one
  summary entry naming the dropped count when the window rolls) behind
  ScriptLogTopicSink (key = script|vtag|alarm) and ScriptLogCompanionSink
  (key = script name).

Refs #734

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 10:17:30 -04:00

138 lines
6.3 KiB
C#

using Serilog;
using Serilog.Core;
using Serilog.Events;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
/// <summary>
/// Per-source rate limit on the script-log fan-out (review R05:9 / R02:P6): a script that logs — or
/// fails — on every evaluation of a 1 Hz input used to put one entry per second per script onto the
/// cluster bus and the main log. The limiter admits the first 20 entries per source per minute, drops
/// the rest, and surfaces one summary line (with the dropped count) when the next entry arrives after
/// the window rolled — so nothing is silently lost, and a chatty script cannot shed everyone else's
/// entries out of the bounded telemetry channels.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ScriptLogRateLimiterTests
{
private sealed class Clock
{
public DateTime Now = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
public void Advance(TimeSpan by) => Now += by;
}
[Fact]
public void Admits_the_first_N_per_window_then_suppresses_and_summarises_once_the_window_rolls()
{
var clock = new Clock();
var limiter = new ScriptLogRateLimiter(limitPerWindow: 3, window: TimeSpan.FromMinutes(1), clock: () => clock.Now);
limiter.Admit("s1").Admitted.ShouldBeTrue();
limiter.Admit("s1").Admitted.ShouldBeTrue();
limiter.Admit("s1").Admitted.ShouldBeTrue();
limiter.Admit("s1").Admitted.ShouldBeFalse();
limiter.Admit("s1").Admitted.ShouldBeFalse();
limiter.Admit("s1").SuppressedInPreviousWindow.ShouldBe(0, "no summary until the window rolls");
clock.Advance(TimeSpan.FromMinutes(1));
var first = limiter.Admit("s1");
first.Admitted.ShouldBeTrue("a fresh window admits again");
first.SuppressedInPreviousWindow.ShouldBe(3, "the caller emits one summary for the three dropped entries");
limiter.Admit("s1").SuppressedInPreviousWindow.ShouldBe(0, "the summary is reported exactly once");
}
[Fact]
public void Sources_are_independent()
{
var limiter = new ScriptLogRateLimiter(limitPerWindow: 1);
limiter.Admit("a").Admitted.ShouldBeTrue();
limiter.Admit("a").Admitted.ShouldBeFalse();
limiter.Admit("b").Admitted.ShouldBeTrue("a chatty source must not shed another source's entries");
}
[Fact]
public void Defaults_are_20_per_minute()
{
ScriptLogRateLimiter.DefaultLimitPerWindow.ShouldBe(20);
ScriptLogRateLimiter.DefaultWindow.ShouldBe(TimeSpan.FromMinutes(1));
var limiter = new ScriptLogRateLimiter();
for (var i = 0; i < 20; i++) limiter.Admit("s").Admitted.ShouldBeTrue();
limiter.Admit("s").Admitted.ShouldBeFalse();
}
// ── the two sinks ────────────────────────────────────────────────────────────────────────
private sealed class FakePublisher : IScriptLogPublisher
{
public List<ScriptLogEntry> Published { get; } = [];
public void Publish(ScriptLogEntry entry) => Published.Add(entry);
}
private static LogEvent Evt(LogEventLevel level, string template, params (string Key, string Val)[] props)
{
var tmpl = new Serilog.Parsing.MessageTemplateParser().Parse(template);
var logProps = props.Select(p => new LogEventProperty(p.Key, new ScalarValue(p.Val)));
return new LogEvent(DateTimeOffset.UtcNow, level, exception: null, tmpl, logProps);
}
[Fact]
public void Topic_sink_rate_limits_per_source_and_publishes_a_summary_entry()
{
var clock = new Clock();
var publisher = new FakePublisher();
var sink = new ScriptLogTopicSink(publisher, LogEventLevel.Information,
new ScriptLogRateLimiter(limitPerWindow: 2, clock: () => clock.Now));
for (var i = 0; i < 5; i++)
sink.Emit(Evt(LogEventLevel.Warning, "chatty", (ScriptLoggerFactory.ScriptIdProperty, "S1"), (ScriptLoggerFactory.VirtualTagIdProperty, "V1")));
sink.Emit(Evt(LogEventLevel.Warning, "other", (ScriptLoggerFactory.ScriptIdProperty, "S2")));
publisher.Published.Count(e => e.ScriptId == "S1").ShouldBe(2, "the limit is per source");
publisher.Published.Count(e => e.ScriptId == "S2").ShouldBe(1, "another source is unaffected");
clock.Advance(TimeSpan.FromMinutes(1));
sink.Emit(Evt(LogEventLevel.Warning, "chatty again", (ScriptLoggerFactory.ScriptIdProperty, "S1"), (ScriptLoggerFactory.VirtualTagIdProperty, "V1")));
var s1 = publisher.Published.Where(e => e.ScriptId == "S1").ToList();
s1.Count.ShouldBe(4, "2 admitted + 1 summary + 1 admitted in the new window");
var summary = s1[2];
summary.Message.ShouldContain("3");
summary.Message.ShouldContain("suppressed");
summary.VirtualTagId.ShouldBe("V1", "the summary carries the source's identity so the page attributes it");
summary.Level.ShouldBe("Warning");
s1[3].Message.ShouldBe("chatty again");
}
private sealed class CapturingSink : ILogEventSink
{
public List<LogEvent> Events { get; } = [];
public void Emit(LogEvent logEvent) => Events.Add(logEvent);
}
[Fact]
public void Companion_sink_rate_limits_per_script_and_mirrors_a_summary_line()
{
var clock = new Clock();
var main = new CapturingSink();
var mainLogger = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(main).CreateLogger();
var sink = new ScriptLogCompanionSink(mainLogger, LogEventLevel.Error,
new ScriptLogRateLimiter(limitPerWindow: 2, clock: () => clock.Now));
for (var i = 0; i < 5; i++)
sink.Emit(Evt(LogEventLevel.Error, "boom", (ScriptLoggerFactory.ScriptNameProperty, "HighTemp")));
main.Events.Count.ShouldBe(2);
clock.Advance(TimeSpan.FromMinutes(1));
sink.Emit(Evt(LogEventLevel.Error, "boom", (ScriptLoggerFactory.ScriptNameProperty, "HighTemp")));
main.Events.Count.ShouldBe(4);
var summary = main.Events[2].RenderMessage();
summary.ShouldContain("HighTemp");
summary.ShouldContain("3");
summary.ShouldContain("suppressed");
}
}