fix(commons): Flow graph orders executions by emission sequence, timestamps only within one clock (arch-review F5)

Central rows carry central's clock, site rows the site's, so the global
OccurredAtUtc sort let a 1-2 s skew float a whole site execution ahead of the
inbound row / RouteCall that started it, and pushed the trigger-cascade spawner
search onto its parentChain[0] fallback.

FlowGraphBuilder.Build now orders executions by the input index of their
earliest-given row and orders steps by timestamp only INSIDE one execution
(one clock), breaking ties by kind rank then input index. A folded outbound
node keeps its earliest-by-time row's identity, which need not be the row given
first, so its index is the minimum over the rows folded into it. The spawner
search uses position in the ordered list rather than a raw timestamp compare.
FlowDebugRunner.RunMethodAsync keeps the collector's emission order instead of
re-sorting; the value-change path keeps its OrderBy (all rows share one site
clock). Replay input arrives timestamp-sorted from the repository, so its order
is unchanged.

The DelmiaShaped test fixture is still shuffled within each execution (the
inside-one-execution timestamp rule is unchanged) but now lists the executions
in emission order, since that is what fixes their relative order.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
Joseph Doherty
2026-09-06 14:28:12 -04:00
parent def6bab318
commit 1dfb9cee20
4 changed files with 183 additions and 11 deletions
@@ -46,8 +46,10 @@ public class FlowGraphBuilderTests
executionId: Cascade, parentExecutionId: SiteRun, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 10,
extra: new ScriptTraceExtra { TriggerKind = "valuechange", TriggerAttribute = "DelmiaReceiver.Trigger", TriggerValue = "false" }.ToJson());
// Deliberately shuffled: the builder must order by time.
return [wait, cascade, inbound, write2, run, route, write1];
// Deliberately shuffled WITHIN each execution: the builder must order by time inside one execution.
// The executions themselves are given in emission order (root, site run, cascade) — since arch-review
// F5 that is what fixes their relative order, because they do not share a clock.
return [inbound, route, wait, run, write2, write1, cascade];
}
[Fact]
@@ -365,6 +367,105 @@ public class FlowGraphBuilderTests
Assert.Equal(graph.Steps[1].EventId, edge.ToEventId);
}
// ---- Arch-review F5: emission order between executions, timestamps within one ------------
/// <summary>
/// Central and site clocks differ. The four rows are given in EMISSION order (the Live path's
/// collector order); the graph must keep the executions in that order whichever way the skew runs.
/// </summary>
private static List<AuditEvent> SkewedRouteShape(int siteSkewMs)
{
var inbound = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
occurredAtUtc: T0, actor: "k", target: "M", sourceNode: "central-a", executionId: Root, durationMs: 900,
requestSummary: "{}", responseSummary: "{\"ok\":true}");
var route = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
occurredAtUtc: T0.AddMilliseconds(50), target: "M1.S", sourceNode: "central-a", executionId: Root,
extra: new ScriptTraceExtra { ChildExecutionId = SiteRun }.ToJson());
var run = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
occurredAtUtc: T0.AddMilliseconds(siteSkewMs), actor: "S", target: "S", sourceNode: "node-a",
executionId: SiteRun, parentExecutionId: Root, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 20,
extra: new ScriptTraceExtra { TriggerKind = "call" }.ToJson());
var write = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered,
occurredAtUtc: T0.AddMilliseconds(siteSkewMs + 10), actor: "S", target: "A", sourceNode: "node-a",
executionId: SiteRun, parentExecutionId: Root, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 2,
extra: new ScriptTraceExtra { Writes = [new AttributeWriteDetail("A", "1", true, null)] }.ToJson());
return [inbound, route, run, write];
}
[Fact]
public void SiteClockAhead_SiteStepsStillFollowTheirRouteCall_AndPrecedeResponse()
{
var events = SkewedRouteShape(siteSkewMs: 2000); // site 2 s ahead of central
var graph = FlowGraphBuilder.Build(events);
Assert.Equal(
new[]
{
FlowStepKind.InboundRequest, FlowStepKind.RouteCall, FlowStepKind.ScriptRun,
FlowStepKind.AttributeWrite, FlowStepKind.Response,
},
graph.Steps.Select(s => s.Kind));
Assert.Equal(events.Select(e => e.EventId), graph.Steps.Take(4).Select(s => s.EventId));
// The Response time still comes off the inbound row's own clock (inbound + DurationMs).
Assert.Equal(T0.AddMilliseconds(900), graph.Steps[4].OccurredAtUtc.UtcDateTime);
}
[Fact]
public void SiteClockBehind_SiteStepsDoNotSortAheadOfTheInboundRequest()
{
var events = SkewedRouteShape(siteSkewMs: -2000); // site 2 s behind central
var graph = FlowGraphBuilder.Build(events);
Assert.Equal(
new[]
{
FlowStepKind.InboundRequest, FlowStepKind.RouteCall, FlowStepKind.ScriptRun,
FlowStepKind.AttributeWrite, FlowStepKind.Response,
},
graph.Steps.Select(s => s.Kind));
Assert.Equal(events.Select(e => e.EventId), graph.Steps.Take(4).Select(s => s.EventId));
}
[Fact]
public void SiteClockBehind_TriggerCascade_SpawnsFromTheLastParentStep_NotTheFirst()
{
// Mixed-version shape: no RouteCall row, so the spawner is found by position, and a site
// clock behind central used to push every parent step "after" the child → parentChain[0].
var inbound = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
occurredAtUtc: T0, actor: "k", target: "M", sourceNode: "central-a", executionId: Root, durationMs: 900);
var centralDb = ScadaBridgeAuditEventFactory.Create(AuditChannel.DbOutbound, AuditKind.DbWrite, AuditStatus.Delivered,
occurredAtUtc: T0.AddMilliseconds(10), actor: "api:M", target: "CfgDb", sourceNode: "central-a", executionId: Root);
var run = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
occurredAtUtc: T0.AddMilliseconds(-2000), actor: "S", target: "S", sourceNode: "node-a",
executionId: SiteRun, parentExecutionId: Root, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 20,
extra: new ScriptTraceExtra { TriggerKind = "call" }.ToJson());
var graph = FlowGraphBuilder.Build([inbound, centralDb, run]);
var spawn = Assert.Single(graph.Edges, e => e.Spawn);
Assert.Equal(centralDb.EventId, spawn.FromEventId); // the LAST parent step, not the inbound row
Assert.Equal(run.EventId, spawn.ToEventId);
}
[Fact]
public void FoldedNode_TakesTheEmissionIndexOfItsEarliestEmittedRow()
{
// The folded step keeps the earliest-by-TIME row's id (the resolve here is emitted first),
// so its sequence must be the minimum input index over the rows folded into it.
var corr = Guid.NewGuid();
var resolve = Outbound(AuditChannel.ApiOutbound, AuditKind.CachedResolve, AuditStatus.Delivered, 4000, corr, "Delmia.Ack", response: "res");
var submit = Outbound(AuditChannel.ApiOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted, 35, corr, "Delmia.Ack", request: "req");
var run = SiteRunRow(20, Cascade);
// Emission order interleaves the executions: the folded node's earliest row is index 0, so
// its execution leads — even though the id the folded step keeps (the submit) is index 2.
var graph = FlowGraphBuilder.Build([resolve, run, submit]);
var api = Assert.Single(graph.Steps, s => s.Kind == FlowStepKind.ApiCall);
Assert.Equal(submit.EventId, api.EventId);
Assert.Equal(new[] { FlowStepKind.ApiCall, FlowStepKind.ScriptRun }, graph.Steps.Select(s => s.Kind));
}
// ---- Review findings on Task 2 (2026-09-06) --------------------------------------------
[Fact]
@@ -124,6 +124,31 @@ public class FlowDebugRunnerTests
Assert.Contains(_central, e => e.AsRow().Kind == AuditKind.RouteCall);
}
[Fact]
public async Task RunMethod_SiteClockBehindCentral_StillReturnsTheSinkEventsInEmissionOrder()
{
// Arch-review F5: the site's rows carry the SITE clock. A site 2 s behind central used to
// re-sort ahead of the always-on central RouteCall row.
var method = new ApiMethod("M", "x") { Id = 6, TimeoutSeconds = 5 };
_apiRepo.GetApiMethodByIdAsync(6, Arg.Any<CancellationToken>()).Returns(method);
_locator.GetSiteIdForInstanceAsync("inst", Arg.Any<CancellationToken>()).Returns("site-a");
var child = Guid.NewGuid();
var siteRun = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
occurredAtUtc: DateTime.UtcNow.AddSeconds(-2), executionId: child, target: "work");
_router.RouteToCallAsync("site-a", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, true, 7, null, DateTimeOffset.UtcNow, child, [siteRun]));
_executor.RegisterHandler("M", ctx => ctx.Route.To("inst").Call("work"));
var result = await Runner().RunMethodAsync(_services.BuildServiceProvider(),
new FlowDebugRunMethodCommand(6, null), Deployer, CancellationToken.None);
Assert.True(result.Completed, result.Error);
Assert.Equal(3, result.Events.Count);
Assert.Equal(AuditKind.InboundRequest, result.Events[0].AsRow().Kind);
Assert.Equal(AuditKind.RouteCall, result.Events[1].AsRow().Kind);
Assert.Same(siteRun, result.Events[2]);
}
[Fact]
public async Task RunMethod_UnknownMethod_IsNotFound()
{