feat(commons): FlowGraphBuilder folds outbound lifecycles into one node; async/spawn edges; Response from the inbound execution
Fold rule: outbound rows sharing (kind, CorrelationId) become one FlowStep with a Lifecycle list — first row's identity/time/input, latest row's outcome/ subtitle/output/error, DurationMs = sync row's own or submit→terminal wall time (null while pending). Chain skips async steps (Notify, or first row CachedSubmit) and links them with Async edges from the preceding chain step. Spawner edges from a non-RouteCall are Spawn: true. The Response links from the last non-async step of the inbound execution (the RouteCall), not the globally last cascaded step (D9). FlowGraphView places a Response at max column + 1 so the D9 origin does not pull it left (pulled forward from Task 7 to keep the layout test green). Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
{
|
||||
"id": 1,
|
||||
"subject": "Task 1: Commons \u2014 FlowStepProjector maps outbound rows",
|
||||
"status": "in_progress",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
@@ -17,7 +17,7 @@
|
||||
{
|
||||
"id": 2,
|
||||
"subject": "Task 2: Commons \u2014 FlowGraphBuilder groups operations, async branches, spawn edges, Response origin",
|
||||
"status": "pending",
|
||||
"status": "in_progress",
|
||||
"blockedBy": [
|
||||
0,
|
||||
1
|
||||
@@ -96,5 +96,5 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-09-06T05:37:29.919766+00:00"
|
||||
"lastUpdated": "2026-09-06T05:38:41.681788+00:00"
|
||||
}
|
||||
@@ -396,6 +396,14 @@ public partial class FlowGraphView : ComponentBase
|
||||
}
|
||||
}
|
||||
|
||||
// The Response is the flow's final node: it sits right of everything real regardless of
|
||||
// its predecessor's column (the builder links it from the route call — D9 — which would
|
||||
// otherwise drag it back to column 2 while the site work sits to its right).
|
||||
var isResponse = (stepByKey.TryGetValue(k, out var sk) && sk.Kind == FlowStepKind.Response)
|
||||
|| (predByKey.TryGetValue(k, out var pk) && pk.Kind == FlowStepKind.Response);
|
||||
if (isResponse && column.Count > 0)
|
||||
col = Math.Max(col, column.Values.Max() + 1);
|
||||
|
||||
while (!occupied.Add((lane[k], col)))
|
||||
col++;
|
||||
column[k] = col;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
|
||||
|
||||
@@ -8,18 +9,27 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
|
||||
/// (Flow Debugger design 2026-09-05 §7.2). Pure: same events in, same graph out.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Rules:
|
||||
/// Rules (v1.1, 2026-09-06 outbound-calls plan):
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Fold.</b> Outbound rows (<c>ApiOutbound</c> / <c>DbOutbound</c> / <c>Notification</c>) sharing a kind and
|
||||
/// <c>CorrelationId</c> fold into ONE step (a sync row with a null correlation is a group of one). The folded step
|
||||
/// takes the first row's identity, time, input and title, the latest row's outcome, subtitle, output and error;
|
||||
/// <c>DurationMs</c> is the sync row's own, or submit→terminal wall time for a cached operation (null while pending);
|
||||
/// <c>Lifecycle</c> lists every row in time order.</item>
|
||||
/// <item>Steps are ordered by <c>OccurredAtUtc</c>, then a kind rank (inbound before route call
|
||||
/// before script run before writes/waits), then <c>EventId</c>.</item>
|
||||
/// <item>Within one <c>ExecutionId</c> consecutive steps are chained in that order.</item>
|
||||
/// before script run before writes/waits before outbound), then <c>EventId</c>.</item>
|
||||
/// <item>Within one <c>ExecutionId</c> consecutive steps are chained in that order, <b>skipping async steps</b> — a
|
||||
/// <c>Notify</c>, or a cached operation whose first row is <c>CachedSubmit</c>. Each skipped step instead gets an
|
||||
/// <c>Async</c> (dashed) edge from the chain step immediately before it in time.</item>
|
||||
/// <item>The first step of an execution is linked from its spawner: a <c>RouteCall</c> whose
|
||||
/// <c>childExecutionId</c> matches, else the latest step of the parent execution that occurred
|
||||
/// no later than it. The edge <c>CrossesBoundary</c> when exactly one end is in the Central lane.</item>
|
||||
/// no later than it — that second case is a <c>Spawn</c> edge (trigger cascade). The edge
|
||||
/// <c>CrossesBoundary</c> when exactly one end is in the Central lane.</item>
|
||||
/// <item>An <c>AttributeWait</c> that was satisfied gets an <c>External</c> (dashed) edge from the
|
||||
/// preceding <c>AttributeWrite</c> in the same execution — the device echo the system inferred.</item>
|
||||
/// <item>When an <c>InboundRequest</c> row carries a response summary, a <c>Response</c> step is
|
||||
/// synthesized as the final Central node, linked from the last real step.</item>
|
||||
/// synthesized as the final Central node, linked from the last non-async step of the <i>inbound</i>
|
||||
/// execution (the route call, or the inbound row itself), never from a cascaded site step.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public static class FlowGraphBuilder
|
||||
@@ -31,8 +41,7 @@ public static class FlowGraphBuilder
|
||||
if (events.Count == 0)
|
||||
return new FlowGraph(Array.Empty<FlowStep>(), Array.Empty<FlowEdge>(), Predicted: false, Truncated: truncated);
|
||||
|
||||
var steps = events
|
||||
.Select(FlowStepProjector.Project)
|
||||
var steps = Fold(events)
|
||||
.OrderBy(s => s.OccurredAtUtc)
|
||||
.ThenBy(s => KindRank(s.Kind))
|
||||
.ThenBy(s => s.EventId)
|
||||
@@ -44,11 +53,23 @@ public static class FlowGraphBuilder
|
||||
.GroupBy(s => s.ExecutionId!.Value)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
// 1. Chain consecutive steps inside each execution.
|
||||
// 1. Chain consecutive non-async steps inside each execution; async steps branch off the step before them.
|
||||
foreach (var chain in byExecution.Values)
|
||||
{
|
||||
for (var i = 1; i < chain.Count; i++)
|
||||
edges.Add(new FlowEdge(chain[i - 1].EventId, chain[i].EventId, CrossesBoundary(chain[i - 1], chain[i]), External: false));
|
||||
FlowStep? prev = null;
|
||||
foreach (var step in chain)
|
||||
{
|
||||
if (IsAsync(step))
|
||||
{
|
||||
if (prev is not null)
|
||||
edges.Add(new FlowEdge(prev.EventId, step.EventId, CrossesBoundary(prev, step), External: false, Async: true));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (prev is not null)
|
||||
edges.Add(new FlowEdge(prev.EventId, step.EventId, CrossesBoundary(prev, step), External: false));
|
||||
prev = step;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Link each child execution's first step from its spawner.
|
||||
@@ -66,11 +87,12 @@ public static class FlowGraphBuilder
|
||||
if (routeCallsByChild.TryGetValue(executionId, out var routeCall))
|
||||
spawner = routeCall;
|
||||
else if (byExecution.TryGetValue(parentId, out var parentChain))
|
||||
spawner = parentChain.LastOrDefault(p => p.OccurredAtUtc <= first.OccurredAtUtc && p.Kind != FlowStepKind.Response)
|
||||
spawner = parentChain.LastOrDefault(p => p.OccurredAtUtc <= first.OccurredAtUtc && p.Kind != FlowStepKind.Response && !IsAsync(p))
|
||||
?? parentChain[0];
|
||||
|
||||
if (spawner is not null && spawner.EventId != first.EventId)
|
||||
edges.Add(new FlowEdge(spawner.EventId, first.EventId, CrossesBoundary(spawner, first), External: false));
|
||||
edges.Add(new FlowEdge(spawner.EventId, first.EventId, CrossesBoundary(spawner, first), External: false,
|
||||
Spawn: spawner.Kind != FlowStepKind.RouteCall));
|
||||
}
|
||||
|
||||
// 3. Satisfied wait ⇐ preceding write in the same execution (inferred device echo).
|
||||
@@ -95,6 +117,10 @@ public static class FlowGraphBuilder
|
||||
if (inbound is not null && inbound.OutputJson is not null)
|
||||
{
|
||||
var last = steps[^1];
|
||||
var origin = (inbound.ExecutionId is { } inboundExecution && byExecution.TryGetValue(inboundExecution, out var inboundChain)
|
||||
? inboundChain.LastOrDefault(s => s.Kind != FlowStepKind.Response && !IsAsync(s))
|
||||
: null)
|
||||
?? last;
|
||||
var response = new FlowStep(
|
||||
EventId: DeriveResponseId(inbound.EventId),
|
||||
ExecutionId: inbound.ExecutionId,
|
||||
@@ -115,12 +141,88 @@ public static class FlowGraphBuilder
|
||||
Target: inbound.Target,
|
||||
Extra: null);
|
||||
steps.Add(response);
|
||||
edges.Add(new FlowEdge(last.EventId, response.EventId, CrossesBoundary(last, response), External: false));
|
||||
edges.Add(new FlowEdge(origin.EventId, response.EventId, CrossesBoundary(origin, response), External: false));
|
||||
}
|
||||
|
||||
return new FlowGraph(steps, edges, Predicted: false, Truncated: truncated);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A step the script did not wait for: a notification, or a cached operation (first lifecycle row
|
||||
/// <c>CachedSubmit</c>). Such steps hang off the chain on an <see cref="FlowEdge.Async"/> edge (D4).
|
||||
/// </summary>
|
||||
public static bool IsAsync(FlowStep step)
|
||||
=> step.Kind == FlowStepKind.Notify
|
||||
|| (step.Kind is FlowStepKind.ApiCall or FlowStepKind.DbCall
|
||||
&& string.Equals(step.Lifecycle?.FirstOrDefault()?.Kind, nameof(AuditKind.CachedSubmit), StringComparison.Ordinal));
|
||||
|
||||
/// <summary>
|
||||
/// Projects every event and folds the outbound rows of one operation (same kind + <c>CorrelationId</c>) into one step
|
||||
/// carrying the whole lifecycle. Non-outbound rows and null-correlation outbound rows pass through one-to-one.
|
||||
/// </summary>
|
||||
private static IEnumerable<FlowStep> Fold(IReadOnlyList<AuditEvent> events)
|
||||
{
|
||||
var groups = new Dictionary<(FlowStepKind Kind, Guid Correlation), List<(FlowStep Step, AuditEvent Evt)>>();
|
||||
var order = new List<object>(events.Count); // FlowStep, or the group key — preserves first-seen order
|
||||
|
||||
foreach (var evt in events)
|
||||
{
|
||||
var step = FlowStepProjector.Project(evt);
|
||||
if (!FlowStepProjector.IsOutbound(step.Kind))
|
||||
{
|
||||
order.Add(step);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (evt.CorrelationId is not { } correlation)
|
||||
{
|
||||
order.Add(step with { Lifecycle = [FlowStepProjector.ToLifecycleRow(evt)] });
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = (step.Kind, correlation);
|
||||
if (!groups.TryGetValue(key, out var group))
|
||||
{
|
||||
group = [];
|
||||
groups[key] = group;
|
||||
order.Add(key);
|
||||
}
|
||||
group.Add((step, evt));
|
||||
}
|
||||
|
||||
foreach (var item in order)
|
||||
{
|
||||
if (item is FlowStep single)
|
||||
{
|
||||
yield return single;
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = ((FlowStepKind, Guid))item;
|
||||
var rows = groups[key]
|
||||
.OrderBy(r => r.Step.OccurredAtUtc)
|
||||
.ThenBy(r => r.Step.EventId)
|
||||
.ToList();
|
||||
var first = rows[0].Step;
|
||||
var latest = rows[^1].Step;
|
||||
var lifecycle = rows.Select(r => FlowStepProjector.ToLifecycleRow(r.Evt)).ToList();
|
||||
var terminal = latest.Outcome != FlowStepOutcome.Pending;
|
||||
int? duration = rows.Count == 1
|
||||
? first.DurationMs
|
||||
: terminal ? (int?)Math.Max(0, (int)Math.Round((latest.OccurredAtUtc - first.OccurredAtUtc).TotalMilliseconds)) : null;
|
||||
|
||||
yield return first with
|
||||
{
|
||||
Outcome = latest.Outcome,
|
||||
Subtitle = latest.Subtitle,
|
||||
OutputJson = latest.OutputJson,
|
||||
ErrorMessage = latest.ErrorMessage,
|
||||
DurationMs = rows.Count == 1 && !terminal ? null : duration,
|
||||
Lifecycle = lifecycle,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Display rank used to break timestamp ties: spawners before what they spawn.</summary>
|
||||
public static int KindRank(FlowStepKind kind) => kind switch
|
||||
{
|
||||
|
||||
@@ -45,6 +45,21 @@ public static class FlowStepProjector
|
||||
Extra: extra);
|
||||
}
|
||||
|
||||
/// <summary>Projects one outbound audit row onto its lifecycle entry (<see cref="FlowStep.Lifecycle"/>).</summary>
|
||||
public static FlowLifecycleRow ToLifecycleRow(AuditEvent evt)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evt);
|
||||
var d = AuditDetailsCodec.Deserialize(evt.DetailsJson);
|
||||
return new FlowLifecycleRow(
|
||||
EventId: evt.EventId,
|
||||
Kind: d.Kind ?? string.Empty,
|
||||
Status: d.Status ?? string.Empty,
|
||||
OccurredAtUtc: evt.OccurredAtUtc,
|
||||
Node: evt.SourceNode,
|
||||
ErrorMessage: d.ErrorMessage,
|
||||
DurationMs: d.DurationMs);
|
||||
}
|
||||
|
||||
/// <summary>Maps a channel/kind string pair onto a step kind.</summary>
|
||||
public static FlowStepKind ResolveKind(string? channel, string? kind)
|
||||
{
|
||||
|
||||
@@ -77,19 +77,26 @@ public class FlowGraphBuilderTests
|
||||
Assert.Equal(FlowLane.Site, graph.Steps[6].Lane);
|
||||
Assert.Equal(FlowLane.Central, graph.Steps[7].Lane);
|
||||
|
||||
// Exactly two boundary crossings: route → site run, and last site step → response.
|
||||
var crossing = graph.Edges.Where(e => e.CrossesBoundary).ToList();
|
||||
Assert.Equal(2, crossing.Count);
|
||||
Assert.Contains(crossing, e => e.FromEventId == graph.Steps[1].EventId && e.ToEventId == graph.Steps[2].EventId);
|
||||
Assert.Contains(crossing, e => e.FromEventId == graph.Steps[6].EventId && e.ToEventId == graph.Steps[7].EventId);
|
||||
// Exactly one boundary crossing: route → site run. The Response links from the RouteCall (D9), Central → Central.
|
||||
var crossing = Assert.Single(graph.Edges, e => e.CrossesBoundary);
|
||||
Assert.Equal(graph.Steps[1].EventId, crossing.FromEventId);
|
||||
Assert.Equal(graph.Steps[2].EventId, crossing.ToEventId);
|
||||
Assert.Contains(graph.Edges, e => e.FromEventId == graph.Steps[1].EventId && e.ToEventId == graph.Steps[7].EventId && !e.CrossesBoundary);
|
||||
Assert.DoesNotContain(graph.Edges, e => e.FromEventId == graph.Steps[6].EventId && e.ToEventId == graph.Steps[7].EventId);
|
||||
|
||||
// One inferred echo edge: first write → satisfied wait.
|
||||
var external = Assert.Single(graph.Edges, e => e.External);
|
||||
Assert.Equal(graph.Steps[3].EventId, external.FromEventId);
|
||||
Assert.Equal(graph.Steps[4].EventId, external.ToEventId);
|
||||
|
||||
// The cascade run hangs off the write that spawned it (same site, no boundary).
|
||||
Assert.Contains(graph.Edges, e => e.FromEventId == graph.Steps[5].EventId && e.ToEventId == graph.Steps[6].EventId && !e.CrossesBoundary);
|
||||
// The cascade run hangs off the write that spawned it (same site, no boundary) — a Spawn edge (D9);
|
||||
// the route → run edge and every in-chain edge are not.
|
||||
var spawn = Assert.Single(graph.Edges, e => e.Spawn);
|
||||
Assert.Equal(graph.Steps[5].EventId, spawn.FromEventId);
|
||||
Assert.Equal(graph.Steps[6].EventId, spawn.ToEventId);
|
||||
Assert.False(spawn.CrossesBoundary);
|
||||
Assert.Contains(graph.Edges, e => e.FromEventId == graph.Steps[1].EventId && e.ToEventId == graph.Steps[2].EventId && !e.Spawn);
|
||||
Assert.DoesNotContain(graph.Edges, e => e.Async);
|
||||
|
||||
// Inbound → route call chained inside the root execution.
|
||||
Assert.Contains(graph.Edges, e => e.FromEventId == graph.Steps[0].EventId && e.ToEventId == graph.Steps[1].EventId);
|
||||
@@ -179,4 +186,181 @@ public class FlowGraphBuilderTests
|
||||
Assert.Null(ScriptTraceExtra.TryParse(null));
|
||||
Assert.Null(ScriptTraceExtra.TryParse("nope"));
|
||||
}
|
||||
|
||||
// ---- Outbound calls (2026-09-06 plan, Task 2) --------------------------------------------
|
||||
|
||||
private static AuditEvent SiteRunRow(int ms, Guid executionId = default) => ScadaBridgeAuditEventFactory.Create(
|
||||
AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
|
||||
occurredAtUtc: T0.AddMilliseconds(ms), actor: "S", target: "S", sourceNode: "node-a",
|
||||
executionId: executionId == default ? SiteRun : executionId, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 800,
|
||||
extra: new ScriptTraceExtra { TriggerKind = "call" }.ToJson());
|
||||
|
||||
private static AuditEvent WriteRow(int ms, string attr) => ScadaBridgeAuditEventFactory.Create(
|
||||
AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered,
|
||||
occurredAtUtc: T0.AddMilliseconds(ms), actor: "S", target: attr, sourceNode: "node-a",
|
||||
executionId: SiteRun, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 2,
|
||||
extra: new ScriptTraceExtra { Writes = [new AttributeWriteDetail(attr, "1", true, null)] }.ToJson());
|
||||
|
||||
private static AuditEvent Outbound(AuditChannel channel, AuditKind kind, AuditStatus status, int ms, Guid? correlation, string target,
|
||||
string node = "node-a", string? error = null, int? durationMs = null, string? request = null, string? response = null)
|
||||
=> ScadaBridgeAuditEventFactory.Create(channel, kind, status,
|
||||
occurredAtUtc: T0.AddMilliseconds(ms), actor: "S", target: target, sourceNode: node, correlationId: correlation,
|
||||
executionId: SiteRun, sourceSiteId: "site-a", sourceInstanceId: "M1", errorMessage: error, durationMs: durationMs,
|
||||
requestSummary: request, responseSummary: response);
|
||||
|
||||
[Fact]
|
||||
public void CachedApiCall_FourRows_FoldIntoOneAsyncNode()
|
||||
{
|
||||
var corr = Guid.NewGuid();
|
||||
var events = new List<AuditEvent>
|
||||
{
|
||||
SiteRunRow(20),
|
||||
WriteRow(30, "A"),
|
||||
Outbound(AuditChannel.ApiOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted, 35, corr, "Delmia.AckRecipe", request: "{\"id\":1}"),
|
||||
WriteRow(40, "B"),
|
||||
Outbound(AuditChannel.ApiOutbound, AuditKind.ApiCallCached, AuditStatus.Forwarded, 2000, corr, "Delmia.AckRecipe"),
|
||||
Outbound(AuditChannel.ApiOutbound, AuditKind.ApiCallCached, AuditStatus.Attempted, 3000, corr, "Delmia.AckRecipe", error: "503", durationMs: 40),
|
||||
Outbound(AuditChannel.ApiOutbound, AuditKind.CachedResolve, AuditStatus.Delivered, 4135, corr, "Delmia.AckRecipe", response: "{\"ok\":true}"),
|
||||
};
|
||||
|
||||
var graph = FlowGraphBuilder.Build(events);
|
||||
|
||||
Assert.Equal(4, graph.Steps.Count);
|
||||
var api = Assert.Single(graph.Steps, s => s.Kind == FlowStepKind.ApiCall);
|
||||
Assert.Equal(FlowLane.External, api.Lane);
|
||||
Assert.Equal(events[2].EventId, api.EventId);
|
||||
Assert.Equal(T0.AddMilliseconds(35), api.OccurredAtUtc.UtcDateTime);
|
||||
Assert.Equal(4, api.Lifecycle!.Count);
|
||||
Assert.Equal(new[] { "CachedSubmit", "ApiCallCached", "ApiCallCached", "CachedResolve" }, api.Lifecycle.Select(r => r.Kind));
|
||||
Assert.Equal("503", api.Lifecycle[2].ErrorMessage);
|
||||
Assert.Equal(FlowStepOutcome.Ok, api.Outcome);
|
||||
Assert.Equal(4100, api.DurationMs);
|
||||
Assert.Equal("{\"id\":1}", api.InputJson);
|
||||
Assert.Equal("{\"ok\":true}", api.OutputJson);
|
||||
Assert.Equal("Delivered · cached", api.Subtitle);
|
||||
Assert.Null(api.ErrorMessage);
|
||||
Assert.Equal(SiteRun, api.ExecutionId);
|
||||
|
||||
var run = graph.Steps[0]; var w1 = graph.Steps[1]; var w2 = graph.Steps[3];
|
||||
Assert.Equal(new[] { FlowStepKind.ScriptRun, FlowStepKind.AttributeWrite, FlowStepKind.ApiCall, FlowStepKind.AttributeWrite },
|
||||
graph.Steps.Select(s => s.Kind));
|
||||
var async = Assert.Single(graph.Edges, e => e.Async);
|
||||
Assert.Equal(w1.EventId, async.FromEventId);
|
||||
Assert.Equal(api.EventId, async.ToEventId);
|
||||
Assert.Contains(graph.Edges, e => e.FromEventId == run.EventId && e.ToEventId == w1.EventId && !e.Async);
|
||||
Assert.Contains(graph.Edges, e => e.FromEventId == w1.EventId && e.ToEventId == w2.EventId && !e.Async);
|
||||
Assert.DoesNotContain(graph.Edges, e => e.FromEventId == api.EventId);
|
||||
Assert.Equal(3, graph.Edges.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SyncApiCall_SitsInChain_NoAsyncEdge()
|
||||
{
|
||||
var events = new List<AuditEvent>
|
||||
{
|
||||
SiteRunRow(20),
|
||||
WriteRow(30, "A"),
|
||||
Outbound(AuditChannel.ApiOutbound, AuditKind.ApiCall, AuditStatus.Delivered, 35, null, "Delmia.AckRecipe", durationMs: 212),
|
||||
WriteRow(40, "B"),
|
||||
};
|
||||
|
||||
var graph = FlowGraphBuilder.Build(events);
|
||||
|
||||
var api = Assert.Single(graph.Steps, s => s.Kind == FlowStepKind.ApiCall);
|
||||
Assert.Equal(212, api.DurationMs);
|
||||
Assert.Single(api.Lifecycle!);
|
||||
Assert.Equal("Delivered · sync", api.Subtitle);
|
||||
Assert.DoesNotContain(graph.Edges, e => e.Async);
|
||||
Assert.Contains(graph.Edges, e => e.FromEventId == graph.Steps[1].EventId && e.ToEventId == api.EventId);
|
||||
Assert.Contains(graph.Edges, e => e.FromEventId == api.EventId && e.ToEventId == graph.Steps[3].EventId);
|
||||
Assert.DoesNotContain(graph.Edges, e => e.FromEventId == graph.Steps[1].EventId && e.ToEventId == graph.Steps[3].EventId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TwoSyncCalls_NullCorrelation_AreNotFoldedTogether()
|
||||
{
|
||||
var events = new List<AuditEvent>
|
||||
{
|
||||
SiteRunRow(20),
|
||||
Outbound(AuditChannel.DbOutbound, AuditKind.DbWrite, AuditStatus.Delivered, 30, null, "MesDb"),
|
||||
Outbound(AuditChannel.DbOutbound, AuditKind.DbWrite, AuditStatus.Failed, 40, null, "MesDb", error: "deadlock"),
|
||||
};
|
||||
|
||||
var graph = FlowGraphBuilder.Build(events);
|
||||
|
||||
Assert.Equal(2, graph.Steps.Count(s => s.Kind == FlowStepKind.DbCall));
|
||||
Assert.Equal(FlowStepOutcome.Failed, graph.Steps[2].Outcome);
|
||||
Assert.Equal(2, graph.Edges.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotifySend_PlusCentralDeliverParked_FoldsIntoOneParkedNotifyNode()
|
||||
{
|
||||
var notificationId = Guid.NewGuid();
|
||||
var events = new List<AuditEvent>
|
||||
{
|
||||
SiteRunRow(20),
|
||||
WriteRow(30, "A"),
|
||||
Outbound(AuditChannel.Notification, AuditKind.NotifySend, AuditStatus.Submitted, 35, notificationId, "Planners", request: "{\"subject\":\"hi\"}"),
|
||||
Outbound(AuditChannel.Notification, AuditKind.NotifyDeliver, AuditStatus.Attempted, 5000, notificationId, "Planners", node: "central-a", error: "smtp down", durationMs: 30),
|
||||
Outbound(AuditChannel.Notification, AuditKind.NotifyDeliver, AuditStatus.Parked, 9000, notificationId, "Planners", node: "central-a", error: "retries exhausted"),
|
||||
};
|
||||
|
||||
var graph = FlowGraphBuilder.Build(events);
|
||||
|
||||
var notify = Assert.Single(graph.Steps, s => s.Kind == FlowStepKind.Notify);
|
||||
Assert.Equal(FlowStepOutcome.Parked, notify.Outcome);
|
||||
Assert.Equal(3, notify.Lifecycle!.Count);
|
||||
Assert.Equal("central-a", notify.Lifecycle[2].Node);
|
||||
Assert.Equal("retries exhausted", notify.ErrorMessage);
|
||||
Assert.Equal(8965, notify.DurationMs);
|
||||
Assert.Equal("Parked", notify.Subtitle);
|
||||
Assert.Equal("node-a", notify.Node);
|
||||
var async = Assert.Single(graph.Edges, e => e.Async);
|
||||
Assert.Equal(graph.Steps[1].EventId, async.FromEventId);
|
||||
Assert.Equal(notify.EventId, async.ToEventId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CachedSubmitOnly_IsPending_WithNoDuration()
|
||||
{
|
||||
var events = new List<AuditEvent>
|
||||
{
|
||||
SiteRunRow(20),
|
||||
Outbound(AuditChannel.DbOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted, 35, Guid.NewGuid(), "MesDb"),
|
||||
};
|
||||
|
||||
var graph = FlowGraphBuilder.Build(events);
|
||||
|
||||
var db = Assert.Single(graph.Steps, s => s.Kind == FlowStepKind.DbCall);
|
||||
Assert.Equal(FlowStepOutcome.Pending, db.Outcome);
|
||||
Assert.Null(db.DurationMs);
|
||||
Assert.Single(db.Lifecycle!);
|
||||
var async = Assert.Single(graph.Edges);
|
||||
Assert.True(async.Async);
|
||||
Assert.Equal(graph.Steps[0].EventId, async.FromEventId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResponseLinksFromTheInboundExecutionsLastStep_NotTheLastCascadedStep()
|
||||
{
|
||||
var events = DelmiaShaped();
|
||||
var graph = FlowGraphBuilder.Build(events);
|
||||
var response = Assert.Single(graph.Steps, s => s.Kind == FlowStepKind.Response);
|
||||
var into = Assert.Single(graph.Edges, e => e.ToEventId == response.EventId);
|
||||
Assert.Equal(graph.Steps[1].EventId, into.FromEventId);
|
||||
Assert.Equal(FlowStepKind.RouteCall, graph.Steps[1].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResponseFallsBackToTheInboundRow_WhenNothingElseSharesItsExecution()
|
||||
{
|
||||
var inbound = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
|
||||
occurredAtUtc: T0, actor: "k", target: "Ping", sourceNode: "central-a", executionId: Root, durationMs: 5, responseSummary: "{}");
|
||||
var graph = FlowGraphBuilder.Build([inbound]);
|
||||
Assert.Equal(2, graph.Steps.Count);
|
||||
var edge = Assert.Single(graph.Edges);
|
||||
Assert.Equal(graph.Steps[0].EventId, edge.FromEventId);
|
||||
Assert.Equal(graph.Steps[1].EventId, edge.ToEventId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user