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
@@ -16,14 +16,17 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
/// 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 before outbound), then <c>EventId</c>.</item>
/// <item><b>Order</b> = emission order between executions, timestamp within one (arch-review F5: central and
/// site clocks differ). Executions are ordered by the input index of their earliest-given row; inside one
/// execution — one clock — steps are ordered by <c>OccurredAtUtc</c>, then a kind rank (inbound before route
/// call before script run before writes/waits before outbound), then the input index. Replay input arrives
/// timestamp-sorted from the repository, so its order is unchanged; the Live path's collector order wins.</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 — that second case is a <c>Spawn</c> edge (trigger cascade). The edge
/// <c>childExecutionId</c> matches, else the last step of the parent execution that precedes it in
/// the order above — 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>
@@ -41,12 +44,47 @@ public static class FlowGraphBuilder
if (events.Count == 0)
return new FlowGraph(Array.Empty<FlowStep>(), Array.Empty<FlowEdge>(), Predicted: false, Truncated: truncated);
var steps = Fold(events)
.OrderBy(s => s.OccurredAtUtc)
// 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
// site execution ahead of the inbound row that started it.
var seq = new Dictionary<Guid, int>(events.Count);
for (var i = 0; i < events.Count; i++)
seq.TryAdd(events[i].EventId, i);
// A folded step keeps the identity of its earliest-by-TIME row, which need not be the row given
// first — so a folded node's index is the minimum over the rows folded into it.
int InputIndex(FlowStep step)
{
var index = seq.TryGetValue(step.EventId, out var own) ? own : int.MaxValue;
if (step.Lifecycle is { Count: > 1 } rows)
{
foreach (var row in rows)
{
if (seq.TryGetValue(row.EventId, out var i) && i < index)
index = i;
}
}
return index;
}
var folded = Fold(events).ToList();
var firstSeqByExecution = folded
.GroupBy(s => s.ExecutionId ?? Guid.Empty)
.ToDictionary(g => g.Key, g => g.Min(InputIndex));
var steps = folded
.OrderBy(s => firstSeqByExecution[s.ExecutionId ?? Guid.Empty]) // executions in emission order
.ThenBy(s => s.OccurredAtUtc) // one clock inside an execution
.ThenBy(s => KindRank(s.Kind))
.ThenBy(s => s.EventId)
.ThenBy(InputIndex)
.ToList();
// Position in the ordered list — the "before" relation the spawner search uses, so a skewed
// clock can no longer push every candidate parent step "after" the child.
var position = new Dictionary<Guid, int>(steps.Count);
for (var i = 0; i < steps.Count; i++)
position[steps[i].EventId] = i;
var edges = new List<FlowEdge>();
var byExecution = steps
.Where(s => s.ExecutionId is not null)
@@ -93,8 +131,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 && !IsAsync(p))
{
// The parent step that precedes the child in DISPLAY order, not by raw timestamp (F5).
var childPosition = position[first.EventId];
spawner = parentChain.LastOrDefault(p => position[p.EventId] < childPosition && p.Kind != FlowStepKind.Response && !IsAsync(p))
?? parentChain[0];
}
if (spawner is null) continue;
var spawn = spawner.Kind != FlowStepKind.RouteCall;
@@ -173,8 +173,12 @@ public sealed class FlowDebugRunner
result.ErrorMessage ?? "Access denied: routed call outside your site scope.");
}
// Arch-review F5: the collector's emission order is authoritative — central rows carry central's
// clock and site rows the site's, so re-sorting by OccurredAtUtc lets a skewed site clock float
// its steps ahead of the RouteCall that started them. (The value-change path below keeps its
// OrderBy: every row it merges was stamped by the same site.)
var ordered = new List<AuditEvent>(events.Count + 1) { inboundRow };
ordered.AddRange(events.OrderBy(e => e.OccurredAtUtc).ThenBy(e => e.EventId));
ordered.AddRange(events);
return new FlowDebugRunResult(
root, result.Success, result.ErrorMessage, result.Success ? null : result.ErrorCode,