From 395f38e01970300e536dfbc262cd23f5fdbb5c34 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 9 Sep 2026 11:33:54 -0400 Subject: [PATCH] feat(commons): FlowGraphBuilder folds ScriptLog rows onto their owning step (D2) --- .../Types/Debugging/FlowGraphBuilder.cs | 93 +++++++- .../Debugging/FlowGraphBuilderLogLineTests.cs | 204 ++++++++++++++++++ 2 files changed, 293 insertions(+), 4 deletions(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderLogLineTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraphBuilder.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraphBuilder.cs index af484b55..7f94d9b6 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraphBuilder.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraphBuilder.cs @@ -43,16 +43,43 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; /// When an InboundRequest row carries a response summary, a Response step is /// synthesized as the final Central node, linked from the last non-async step of the inbound /// execution (the route call, or the inbound row itself), never from a cascaded site step. +/// Log lines are never steps (step-logs design 2026-09-09 D2). ScriptLog rows are +/// partitioned out before anything else — before the input-order index — and folded onto a step's +/// : the step its StepEventId names, else the anchor of its +/// execution (the InboundRequest, RouteCall or ScriptRun step, in that order), +/// else that execution's first non-Response step. A line whose execution has no step at all is +/// dropped and counted in . Within a step, lines are ordered +/// by OccurredAtUtc then Seq, with a null Seq sorting last, so same-millisecond +/// lines keep their emission order. /// /// public static class FlowGraphBuilder { - /// Builds the graph. is passed through to . - public static FlowGraph Build(IReadOnlyList events, bool truncated = false) + /// + /// Builds the graph. and are passed + /// through to / . + /// + public static FlowGraph Build(IReadOnlyList events, bool truncated = false, bool logsTruncated = false) { ArgumentNullException.ThrowIfNull(events); + + // D2: a ScriptLog row is a line, never a node. Partition BEFORE the input-order index below, so + // `seq` indexes only real step rows and a line can never shift an execution's display position. + var lines = new List(); + var stepEvents = new List(events.Count); + foreach (var e in events) + { + if (FlowStepProjector.IsLogLine(e)) + lines.Add(FlowStepProjector.ToLogLine(e)); + else + stepEvents.Add(e); + } + + events = stepEvents; + if (events.Count == 0) - return new FlowGraph(Array.Empty(), Array.Empty(), Predicted: false, Truncated: truncated); + return new FlowGraph(Array.Empty(), Array.Empty(), Predicted: false, Truncated: truncated, + LogsTruncated: logsTruncated, UnattachedLogLines: lines.Count); // Arch-review F5: a step's position in the input is the only cross-node ordering we can trust — // central rows carry central's clock, site rows the site's, and a 1-2 s skew used to sort a whole @@ -230,7 +257,65 @@ public static class FlowGraphBuilder edges.Add(new FlowEdge(origin.EventId, response.EventId, CrossesBoundary(origin, response), External: false)); } - return new FlowGraph(steps, edges, Predicted: false, Truncated: truncated); + // 5. Fold the log lines onto their owning steps (D2) — after the Response node joined `steps`, + // so a line naming it (none should) still resolves rather than counting as unattached. + AttachLogLines(steps, lines, out var unattached); + + return new FlowGraph(steps, edges, Predicted: false, Truncated: truncated, + LogsTruncated: logsTruncated, UnattachedLogLines: unattached); + } + + /// + /// Attaches each log line to its step (D2): the step named by , + /// else the anchor of the line's execution (InboundRequest before RouteCall before + /// ScriptRun — an inbound row and its route call share one central execution), else that + /// execution's first non-Response step. Lines that resolve to nothing are counted in + /// . Rewrites in place. + /// + private static void AttachLogLines(List steps, List lines, out int unattached) + { + unattached = 0; + if (lines.Count == 0) return; + + var byEventId = new Dictionary(steps.Count); + foreach (var step in steps) + byEventId[step.EventId] = step; + + var anchorByExecution = steps + .Where(s => s.ExecutionId is not null + && s.Kind is FlowStepKind.ScriptRun or FlowStepKind.InboundRequest or FlowStepKind.RouteCall) + .GroupBy(s => s.ExecutionId!.Value) + .ToDictionary(g => g.Key, g => g.OrderBy(s => KindRank(s.Kind)).First()); + var firstByExecution = steps + .Where(s => s.ExecutionId is not null && s.Kind != FlowStepKind.Response) + .GroupBy(s => s.ExecutionId!.Value) + .ToDictionary(g => g.Key, g => g.First()); + + var logsByStep = new Dictionary>(); + foreach (var line in lines) + { + FlowStep? owner = null; + if (line.StepEventId is { } stepId && byEventId.TryGetValue(stepId, out var named)) + owner = named; + else if (line.ExecutionId is { } execution + && (anchorByExecution.TryGetValue(execution, out var fallback) + || firstByExecution.TryGetValue(execution, out fallback))) + owner = fallback; + + if (owner is null) + { + unattached++; + continue; + } + + (logsByStep.TryGetValue(owner.EventId, out var l) ? l : logsByStep[owner.EventId] = []).Add(line); + } + + for (var i = 0; i < steps.Count; i++) + { + if (logsByStep.TryGetValue(steps[i].EventId, out var l)) + steps[i] = steps[i] with { Logs = l.OrderBy(x => x.OccurredAtUtc).ThenBy(x => x.Seq ?? int.MaxValue).ToList() }; + } } /// diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderLogLineTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderLogLineTests.cs new file mode 100644 index 00000000..0a132fe0 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderLogLineTests.cs @@ -0,0 +1,204 @@ +using ZB.MOM.WW.Audit; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Debugging; + +/// +/// Step-logs design 2026-09-09 D2: a ScriptTrace.ScriptLog row is never a node — it folds onto the +/// step its StepEventId names, else onto its execution's anchor (ScriptRun / +/// InboundRequest / RouteCall), else its execution's first non-Response step, else it is +/// dropped and counted in . +/// +public class FlowGraphBuilderLogLineTests +{ + private static readonly DateTime T0 = new(2026, 9, 9, 12, 0, 0, DateTimeKind.Utc); + + private static AuditEvent Run(Guid execution, Guid? parent = null, int ms = 0) => + ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, + occurredAtUtc: T0.AddMilliseconds(ms), actor: "SomeScript", target: "SomeScript", sourceNode: "node-a", + executionId: execution, parentExecutionId: parent, sourceSiteId: "site-a", sourceInstanceId: "M1", + durationMs: 100, extra: new ScriptTraceExtra { TriggerKind = "call" }.ToJson()); + + private static AuditEvent Write(Guid execution, Guid? eventId = null, int ms = 10) => + ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered, + eventId: eventId, occurredAtUtc: T0.AddMilliseconds(ms), actor: "SomeScript", target: "M1.Flag", + sourceNode: "node-a", executionId: execution, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 2, + extra: new ScriptTraceExtra { Writes = [new AttributeWriteDetail("M1.Flag", "true", true, null)] }.ToJson()); + + private static AuditEvent Inbound(Guid execution, int ms = 0) => + ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered, + occurredAtUtc: T0.AddMilliseconds(ms), actor: "some-key", target: "SomeMethod", sourceNode: "central-a", + executionId: execution, durationMs: 50, requestSummary: "{}", responseSummary: "{\"ok\":true}"); + + private static AuditEvent RouteCallRoot(Guid execution, int ms = 0) => + ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered, + occurredAtUtc: T0.AddMilliseconds(ms), actor: "flow-debugger", target: "M1.SomeScript", sourceNode: "central-a", + executionId: execution, durationMs: 120); + + private static AuditEvent ApiOut(Guid execution, Guid eventId, int ms = 20) => + ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.ApiCall, AuditStatus.Delivered, + eventId: eventId, occurredAtUtc: T0.AddMilliseconds(ms), actor: "SomeScript", target: "ERP/orders", + sourceNode: "node-a", executionId: execution, sourceSiteId: "site-a", durationMs: 30); + + private static AuditEvent Line(Guid execution, Guid? step = null, int? seq = null, string level = "info", + int ms = 5, string message = "hello") => + ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptLog, AuditStatus.Delivered, + occurredAtUtc: T0.AddMilliseconds(ms), actor: "SomeScript", target: "SomeScript", sourceNode: "node-a", + executionId: execution, sourceSiteId: "site-a", sourceInstanceId: "M1", requestSummary: message, + extra: new ScriptTraceExtra { LogLevel = level, LogSource = "script", StepEventId = step, Seq = seq }.ToJson()); + + private static FlowStep StepOf(FlowGraph graph, FlowStepKind kind) => Assert.Single(graph.Steps, s => s.Kind == kind); + + [Fact] + public void LogLine_WithStepEventId_AttachesToThatStep() + { + var execution = Guid.NewGuid(); + var writeId = Guid.NewGuid(); + + var graph = FlowGraphBuilder.Build([Run(execution), Write(execution, writeId), Line(execution, step: writeId)]); + + var write = StepOf(graph, FlowStepKind.AttributeWrite); + var line = Assert.Single(write.Logs!); + Assert.Equal("hello", line.Message); + Assert.Null(StepOf(graph, FlowStepKind.ScriptRun).Logs); + } + + [Fact] + public void LogLine_WithoutStepId_AttachesToTheExecutionAnchor() + { + var execution = Guid.NewGuid(); + + var graph = FlowGraphBuilder.Build([Run(execution), Write(execution), Line(execution)]); + + Assert.Single(StepOf(graph, FlowStepKind.ScriptRun).Logs!); + Assert.Null(StepOf(graph, FlowStepKind.AttributeWrite).Logs); + } + + [Fact] + public void CentralLine_AttachesToInboundRow_NotResponse() + { + var execution = Guid.NewGuid(); + + var graph = FlowGraphBuilder.Build([Inbound(execution), Line(execution)]); + + Assert.Single(StepOf(graph, FlowStepKind.InboundRequest).Logs!); + Assert.Null(StepOf(graph, FlowStepKind.Response).Logs); + } + + [Fact] + public void ScriptEntryLine_AttachesToRouteCallRoot() + { + var execution = Guid.NewGuid(); + + var graph = FlowGraphBuilder.Build([RouteCallRoot(execution), Line(execution)]); + + Assert.Single(StepOf(graph, FlowStepKind.RouteCall).Logs!); + } + + [Fact] + public void LogLine_UnknownStepId_FallsBackToAnchor() + { + var execution = Guid.NewGuid(); + + var graph = FlowGraphBuilder.Build([Run(execution), Line(execution, step: Guid.NewGuid())]); + + Assert.Single(StepOf(graph, FlowStepKind.ScriptRun).Logs!); + Assert.Equal(0, graph.UnattachedLogLines); + } + + [Fact] + public void LogLine_NoAnchor_FallsBackToFirstStep() + { + var execution = Guid.NewGuid(); + + var graph = FlowGraphBuilder.Build([Write(execution), Line(execution)]); + + Assert.Single(StepOf(graph, FlowStepKind.AttributeWrite).Logs!); + Assert.Equal(0, graph.UnattachedLogLines); + } + + [Fact] + public void LogLine_NoStepInExecution_IsCountedUnattached() + { + var execution = Guid.NewGuid(); + + var graph = FlowGraphBuilder.Build([Run(execution), Line(Guid.NewGuid())]); + + Assert.Equal(1, graph.UnattachedLogLines); + Assert.All(graph.Steps, s => Assert.True(s.Logs is null or { Count: 0 })); + } + + [Fact] + public void Build_LinesOnly_ReturnsEmptyGraphWithUnattachedCount() + { + var graph = FlowGraphBuilder.Build([Line(Guid.NewGuid()), Line(Guid.NewGuid())], logsTruncated: true); + + Assert.Empty(graph.Steps); + Assert.Empty(graph.Edges); + Assert.Equal(2, graph.UnattachedLogLines); + Assert.True(graph.LogsTruncated); + } + + [Fact] + public void Lines_OrderBySeqWithinSameMillisecond_NullSeqLast() + { + var execution = Guid.NewGuid(); + + var graph = FlowGraphBuilder.Build( + [ + Run(execution), + Line(execution, seq: 3, ms: 5, message: "three"), + Line(execution, seq: 1, ms: 5, message: "one"), + Line(execution, seq: null, ms: 5, message: "null"), + Line(execution, seq: 2, ms: 5, message: "two"), + ]); + + var logs = StepOf(graph, FlowStepKind.ScriptRun).Logs!; + Assert.Equal(new int?[] { 1, 2, 3, null }, logs.Select(l => l.Seq).ToArray()); + Assert.Equal(new[] { "one", "two", "three", "null" }, logs.Select(l => l.Message).ToArray()); + } + + [Fact] + public void LogLines_NeverBecomeSteps() + { + var execution = Guid.NewGuid(); + var events = new List { Run(execution) }; + for (var i = 0; i < 5; i++) + events.Add(Line(execution, seq: i, ms: 1 + i, message: $"line {i}")); + var lineIds = events.Skip(1).Select(e => e.EventId).ToHashSet(); + + var graph = FlowGraphBuilder.Build(events); + + var step = Assert.Single(graph.Steps); + Assert.Equal(FlowStepKind.ScriptRun, step.Kind); + Assert.Equal(5, step.Logs!.Count); + Assert.DoesNotContain(graph.Edges, e => lineIds.Contains(e.FromEventId) || lineIds.Contains(e.ToEventId)); + } + + [Fact] + public void Build_PassesLogsTruncatedThrough() + { + var execution = Guid.NewGuid(); + + var graph = FlowGraphBuilder.Build([Run(execution)], truncated: false, logsTruncated: true); + + Assert.True(graph.LogsTruncated); + Assert.False(graph.Truncated); + } + + [Fact] + public void LogLine_OnFoldedOutboundStep_AttachesByFirstRowId() + { + var execution = Guid.NewGuid(); + var callId = Guid.NewGuid(); + + var graph = FlowGraphBuilder.Build([Run(execution), ApiOut(execution, callId), Line(execution, step: callId)]); + + var call = StepOf(graph, FlowStepKind.ApiCall); + Assert.Equal(callId, call.EventId); + Assert.Single(call.Logs!); + Assert.Null(StepOf(graph, FlowStepKind.ScriptRun).Logs); + } +}