fix(commons): FlowGraphBuilder review fixes — leading async steps link from the spawner, robust async rule, lifecycle tie-break, total DbSubtitle

Review of Task 2 (2026-09-06 outbound-calls plan): (1) an outbound step that
precedes the first chain step of its execution — the default Replay shape when
a run's ScriptTrace rows are absent — was orphaned or given a solid spawner
edge; leading async steps now hang off the spawner on Async edges and the
first non-async step gets the normal link. (2) IsAsync keyed on the first row
being CachedSubmit; it now mirrors the projector's sync rule (first row not
ApiCall/DbWrite) so a group missing its submit row still branches. (3) Same-
tick rows tie-broke by GUID; new LifecycleRank (submit < attempt < resolve,
non-terminal < terminal) makes first/latest attribution order-independent.
(4) DbSubtitle used GetInt32 (FormatException on out-of-range/fractional) —
now TryGetInt32, never throws. Also: External-lane edges never count as
central↔site crossings; DurationMs clamped; FlowStep doc updated.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
Joseph Doherty
2026-09-06 01:49:40 -04:00
parent 30faee0d0e
commit 865c4be651
5 changed files with 183 additions and 19 deletions
@@ -363,4 +363,102 @@ public class FlowGraphBuilderTests
Assert.Equal(graph.Steps[0].EventId, edge.FromEventId);
Assert.Equal(graph.Steps[1].EventId, edge.ToEventId);
}
// ---- Review findings on Task 2 (2026-09-06) --------------------------------------------
[Fact]
public void LeadingAsyncSteps_HangOffTheSpawner_NeverOrphaned()
{
// A routed run whose ScriptTrace rows are absent (TraceAllCalls off / older site): only its outbound rows survive.
var route = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
occurredAtUtc: T0.AddMilliseconds(10), target: "M1.S", sourceNode: "central-a", executionId: Root,
extra: new ScriptTraceExtra { ChildExecutionId = SiteRun }.ToJson());
var cached = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted,
occurredAtUtc: T0.AddMilliseconds(20), target: "Delmia.Ack", correlationId: Guid.NewGuid(), executionId: SiteRun, parentExecutionId: Root, sourceSiteId: "site-a");
var notify = ScadaBridgeAuditEventFactory.Create(AuditChannel.Notification, AuditKind.NotifySend, AuditStatus.Submitted,
occurredAtUtc: T0.AddMilliseconds(30), target: "Planners", correlationId: Guid.NewGuid(), executionId: SiteRun, parentExecutionId: Root, sourceSiteId: "site-a");
var sync = ScadaBridgeAuditEventFactory.Create(AuditChannel.DbOutbound, AuditKind.DbWrite, AuditStatus.Delivered,
occurredAtUtc: T0.AddMilliseconds(40), target: "MesDb", executionId: SiteRun, parentExecutionId: Root, sourceSiteId: "site-a");
var graph = FlowGraphBuilder.Build([route, cached, notify, sync]);
var routeStep = graph.Steps[0];
var reached = graph.Edges.Select(e => e.ToEventId).ToHashSet();
Assert.All(graph.Steps.Skip(1), s => Assert.Contains(s.EventId, reached));
Assert.Equal(2, graph.Edges.Count(e => e.Async && e.FromEventId == routeStep.EventId));
var syncEdge = Assert.Single(graph.Edges, e => e.ToEventId == graph.Steps[3].EventId);
Assert.Equal(routeStep.EventId, syncEdge.FromEventId);
Assert.False(syncEdge.Async);
Assert.False(syncEdge.Spawn);
Assert.Equal(3, graph.Edges.Count);
}
[Fact]
public void OnlyNotifyRows_InAnExecution_AllLinkFromTheSpawner()
{
var run = SiteRunRow(20);
var n1 = ScadaBridgeAuditEventFactory.Create(AuditChannel.Notification, AuditKind.NotifySend, AuditStatus.Submitted,
occurredAtUtc: T0.AddMilliseconds(30), target: "A", correlationId: Guid.NewGuid(), executionId: Cascade, parentExecutionId: SiteRun, sourceSiteId: "site-a");
var n2 = ScadaBridgeAuditEventFactory.Create(AuditChannel.Notification, AuditKind.NotifySend, AuditStatus.Submitted,
occurredAtUtc: T0.AddMilliseconds(31), target: "B", correlationId: Guid.NewGuid(), executionId: Cascade, parentExecutionId: SiteRun, sourceSiteId: "site-a");
var graph = FlowGraphBuilder.Build([run, n1, n2]);
Assert.Equal(2, graph.Edges.Count);
Assert.All(graph.Edges, e => { Assert.True(e.Async); Assert.True(e.Spawn); Assert.Equal(graph.Steps[0].EventId, e.FromEventId); });
}
[Fact]
public void Fold_IsOrderIndependent_AndTiesBreakByLifecycleRank()
{
var corr = Guid.NewGuid();
var submit = Outbound(AuditChannel.ApiOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted, 35, corr, "Delmia.Ack", request: "req");
var resolve = Outbound(AuditChannel.ApiOutbound, AuditKind.CachedResolve, AuditStatus.Delivered, 35, corr, "Delmia.Ack", response: "res");
var a = FlowGraphBuilder.Build([SiteRunRow(20), resolve, submit]);
var b = FlowGraphBuilder.Build([SiteRunRow(20), submit, resolve]);
var sa = Assert.Single(a.Steps, s => s.Kind == FlowStepKind.ApiCall);
var sb = Assert.Single(b.Steps, s => s.Kind == FlowStepKind.ApiCall);
Assert.Equal(submit.EventId, sa.EventId);
Assert.Equal(sa.EventId, sb.EventId);
Assert.Equal("req", sa.InputJson);
Assert.Equal("res", sa.OutputJson);
Assert.Equal(FlowStepOutcome.Ok, sa.Outcome);
Assert.Equal(0, sa.DurationMs);
Assert.True(FlowGraphBuilder.IsAsync(sa));
Assert.Equal(sa.Lifecycle!.Select(r => r.Kind), sb.Lifecycle!.Select(r => r.Kind));
}
[Fact]
public void CachedStep_WhoseSubmitRowIsMissing_IsStillAsync()
{
var corr = Guid.NewGuid();
var graph = FlowGraphBuilder.Build([
SiteRunRow(20),
Outbound(AuditChannel.ApiOutbound, AuditKind.ApiCallCached, AuditStatus.Attempted, 3000, corr, "Delmia.Ack"),
Outbound(AuditChannel.ApiOutbound, AuditKind.CachedResolve, AuditStatus.Delivered, 4000, corr, "Delmia.Ack"),
]);
var api = Assert.Single(graph.Steps, s => s.Kind == FlowStepKind.ApiCall);
Assert.True(FlowGraphBuilder.IsAsync(api));
Assert.Single(graph.Edges, e => e.Async && e.ToEventId == api.EventId);
}
[Fact]
public void ExternalLaneEdges_NeverCountAsClusterBoundaryCrossings()
{
var inbound = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
occurredAtUtc: T0, target: "M", sourceNode: "central-a", executionId: Root, durationMs: 50, responseSummary: "{}");
var centralDb = ScadaBridgeAuditEventFactory.Create(AuditChannel.DbOutbound, AuditKind.DbWrite, AuditStatus.Delivered,
occurredAtUtc: T0.AddMilliseconds(5), actor: "api:M", target: "CfgDb", sourceNode: "central-a", executionId: Root,
extra: "{\"op\":\"read\",\"rowsReturned\":2}");
var graph = FlowGraphBuilder.Build([inbound, centralDb]);
Assert.DoesNotContain(graph.Edges, e => e.CrossesBoundary);
Assert.Equal("read · 2 rows", graph.Steps[1].Subtitle);
// Response links from the DB call — the inbound execution's last sync step.
var into = Assert.Single(graph.Edges, e => e.ToEventId == graph.Steps[2].EventId);
Assert.Equal(graph.Steps[1].EventId, into.FromEventId);
}
}
@@ -148,4 +148,17 @@ public class FlowStepProjectorTests
Assert.Equal(FlowStepOutcome.Ok, FlowStepProjector.Project(ok).Outcome);
Assert.Equal(FlowStepOutcome.Failed, FlowStepProjector.Project(failed).Outcome);
}
[Theory]
[InlineData("{\"op\":\"write\",\"rowsAffected\":2147483648}", "write")]
[InlineData("{\"op\":\"write\",\"rowsAffected\":1.5}", "write")]
[InlineData("{\"op\":\"write\",\"rowsAffected\":-1}", "write")]
[InlineData("{\"op\":7}", "Delivered")]
[InlineData("not json", "Delivered")]
public void DbSubtitle_NeverThrows_OnOddExtra(string extra, string expected)
{
var evt = ScadaBridgeAuditEventFactory.Create(AuditChannel.DbOutbound, AuditKind.DbWrite, AuditStatus.Delivered,
occurredAtUtc: T0.UtcDateTime, target: "MesDb", extra: extra);
Assert.Equal(expected, FlowStepProjector.Project(evt).Subtitle);
}
}