diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/FlowTraceCollector.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/FlowTraceCollector.cs new file mode 100644 index 00000000..8dc855bc --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/FlowTraceCollector.cs @@ -0,0 +1,51 @@ +using ZB.MOM.WW.Audit; + +namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; + +/// +/// Per-run sink for ScriptTrace audit events so a traced routed call can return them +/// inline on ScriptCallResult.TraceEvents (Flow Debugger design 2026-09-05 §5.4). +/// Lock-based: a run emits tens of events, not thousands. Beyond +/// the collector keeps the first N, sets , and the audit rows written +/// through IAuditWriter remain complete — Replay is never truncated, only the inline copy. +/// An untraced run holds null rather than an inert collector; callers null-check. +/// +public sealed class FlowTraceCollector +{ + /// Default inline cap per run (design §5.4 MaxTraceEventsPerRun). + public const int DefaultMaxEvents = 500; + + private readonly List _events = new(); + private readonly object _gate = new(); + private readonly int _max; + + /// True once an was dropped because the cap was reached. + public bool Truncated { get; private set; } + + /// Creates a collector with the given inline cap (must be positive). + public FlowTraceCollector(int maxEvents = DefaultMaxEvents) + { + if (maxEvents <= 0) throw new ArgumentOutOfRangeException(nameof(maxEvents), "must be positive"); + _max = maxEvents; + } + + /// Appends an event, or marks the collector truncated once the cap is reached. + public void Add(AuditEvent evt) + { + ArgumentNullException.ThrowIfNull(evt); + lock (_gate) + { + if (_events.Count >= _max) { Truncated = true; return; } + _events.Add(evt); + } + } + + /// Current event count. + public int Count { get { lock (_gate) return _events.Count; } } + + /// Copies the collected events in emission order. + public IReadOnlyList Snapshot() + { + lock (_gate) return _events.ToArray(); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/FlowTraceCollectorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/FlowTraceCollectorTests.cs new file mode 100644 index 00000000..c5a18319 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/FlowTraceCollectorTests.cs @@ -0,0 +1,67 @@ +using ZB.MOM.WW.Audit; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; + +namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; + +/// Flow Debugger design 2026-09-05 §5.4: the per-run inline trace sink. +public class FlowTraceCollectorTests +{ + private static AuditEvent Evt(int i) => new() + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTimeOffset.UtcNow, + Actor = "t", + Action = "ScriptTrace.AttributeWrite", + Outcome = AuditOutcome.Success, + Target = i.ToString(), + }; + + [Fact] + public void Add_BeyondCap_KeepsFirstN_AndFlagsTruncated() + { + var c = new FlowTraceCollector(maxEvents: 500); + Assert.False(c.Truncated); + + for (var i = 0; i < 501; i++) c.Add(Evt(i)); + + var snap = c.Snapshot(); + Assert.Equal(500, snap.Count); + Assert.True(c.Truncated); + Assert.Equal("0", snap[0].Target); + Assert.Equal("499", snap[499].Target); + } + + [Fact] + public void Snapshot_IsACopy_InEmissionOrder() + { + var c = new FlowTraceCollector(); + c.Add(Evt(1)); + var first = c.Snapshot(); + c.Add(Evt(2)); + + Assert.Single(first); + Assert.Equal(2, c.Count); + Assert.Equal(new[] { "1", "2" }, c.Snapshot().Select(e => e.Target)); + } + + [Fact] + public async Task ConcurrentAdds_NeverThrow_AndNeverExceedCap() + { + var c = new FlowTraceCollector(maxEvents: 100); + var tasks = Enumerable.Range(0, 8).Select(t => Task.Run(() => + { + for (var i = 0; i < 50; i++) c.Add(Evt(t * 100 + i)); + })); + await Task.WhenAll(tasks); + + Assert.Equal(100, c.Snapshot().Count); + Assert.True(c.Truncated); + } + + [Fact] + public void DefaultCap_Is500_AndNonPositiveCapIsRejected() + { + Assert.Equal(500, FlowTraceCollector.DefaultMaxEvents); + Assert.Throws(() => new FlowTraceCollector(0)); + } +}