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:
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user