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:
@@ -53,8 +53,12 @@ public static class FlowGraphBuilder
|
||||
.GroupBy(s => s.ExecutionId!.Value)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
// 1. Chain consecutive non-async steps inside each execution; async steps branch off the step before them.
|
||||
foreach (var chain in byExecution.Values)
|
||||
// 1. Chain consecutive non-async steps inside each execution; async steps branch off the step
|
||||
// before them. Async steps that precede the first chain step ("leading" — the default Replay
|
||||
// shape when a run has no ScriptTrace rows and only its outbound rows survive) are linked
|
||||
// from the execution's spawner in step 2 instead, so nothing is orphaned.
|
||||
var leadingAsync = new Dictionary<Guid, List<FlowStep>>();
|
||||
foreach (var (executionId, chain) in byExecution)
|
||||
{
|
||||
FlowStep? prev = null;
|
||||
foreach (var step in chain)
|
||||
@@ -63,6 +67,8 @@ public static class FlowGraphBuilder
|
||||
{
|
||||
if (prev is not null)
|
||||
edges.Add(new FlowEdge(prev.EventId, step.EventId, CrossesBoundary(prev, step), External: false, Async: true));
|
||||
else
|
||||
(leadingAsync.TryGetValue(executionId, out var l) ? l : leadingAsync[executionId] = []).Add(step);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -90,9 +96,19 @@ public static class FlowGraphBuilder
|
||||
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,
|
||||
Spawn: spawner.Kind != FlowStepKind.RouteCall));
|
||||
if (spawner is null) continue;
|
||||
var spawn = spawner.Kind != FlowStepKind.RouteCall;
|
||||
|
||||
// Leading async steps hang off the spawner on Async edges; the first chain step (if any) gets the normal link.
|
||||
if (leadingAsync.TryGetValue(executionId, out var leading))
|
||||
{
|
||||
foreach (var a in leading)
|
||||
edges.Add(new FlowEdge(spawner.EventId, a.EventId, CrossesBoundary(spawner, a), External: false, Async: true, Spawn: spawn));
|
||||
}
|
||||
|
||||
var firstChained = chain.FirstOrDefault(s => !IsAsync(s));
|
||||
if (firstChained is not null && spawner.EventId != firstChained.EventId)
|
||||
edges.Add(new FlowEdge(spawner.EventId, firstChained.EventId, CrossesBoundary(spawner, firstChained), External: false, Spawn: spawn));
|
||||
}
|
||||
|
||||
// 3. Satisfied wait ⇐ preceding write in the same execution (inferred device echo).
|
||||
@@ -148,13 +164,44 @@ public static class FlowGraphBuilder
|
||||
}
|
||||
|
||||
/// <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).
|
||||
/// A step the script did not wait for: a notification, or a cached operation — any API / DB step whose
|
||||
/// first lifecycle row is not the sync one-shot kind (<c>ApiCall</c> / <c>DbWrite</c>), the same rule the
|
||||
/// projector uses for the <c>sync</c> / <c>cached</c> subtitle. 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));
|
||||
{
|
||||
if (step.Kind == FlowStepKind.Notify) return true;
|
||||
if (step.Kind is not (FlowStepKind.ApiCall or FlowStepKind.DbCall)) return false;
|
||||
var firstKind = step.Lifecycle?.FirstOrDefault()?.Kind;
|
||||
return firstKind is not null
|
||||
&& !string.Equals(firstKind, nameof(AuditKind.ApiCall), StringComparison.Ordinal)
|
||||
&& !string.Equals(firstKind, nameof(AuditKind.DbWrite), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Orders the rows of one outbound operation when timestamps tie: submit before attempts before the
|
||||
/// resolve, then non-terminal before terminal statuses — so "first" and "latest" (D3) never fall back
|
||||
/// to GUID order for rows written in the same tick.
|
||||
/// </summary>
|
||||
public static int LifecycleRank(string? kind, string? status)
|
||||
{
|
||||
var k = kind switch
|
||||
{
|
||||
nameof(AuditKind.CachedSubmit) or nameof(AuditKind.NotifySend) => 0,
|
||||
nameof(AuditKind.ApiCallCached) or nameof(AuditKind.DbWriteCached) or nameof(AuditKind.NotifyDeliver) => 1,
|
||||
nameof(AuditKind.CachedResolve) => 2,
|
||||
_ => 1,
|
||||
};
|
||||
var st = status switch
|
||||
{
|
||||
nameof(AuditStatus.Submitted) => 0,
|
||||
nameof(AuditStatus.Forwarded) => 1,
|
||||
nameof(AuditStatus.Attempted) => 2,
|
||||
_ => 3,
|
||||
};
|
||||
return k * 4 + st;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Projects every event and folds the outbound rows of one operation (same kind + <c>CorrelationId</c>) into one step
|
||||
@@ -200,16 +247,18 @@ public static class FlowGraphBuilder
|
||||
|
||||
var key = ((FlowStepKind, Guid))item;
|
||||
var rows = groups[key]
|
||||
.OrderBy(r => r.Step.OccurredAtUtc)
|
||||
.ThenBy(r => r.Step.EventId)
|
||||
.Select(r => (r.Step, Row: FlowStepProjector.ToLifecycleRow(r.Evt)))
|
||||
.OrderBy(r => r.Row.OccurredAtUtc)
|
||||
.ThenBy(r => LifecycleRank(r.Row.Kind, r.Row.Status))
|
||||
.ThenBy(r => r.Row.EventId)
|
||||
.ToList();
|
||||
var first = rows[0].Step;
|
||||
var latest = rows[^1].Step;
|
||||
var lifecycle = rows.Select(r => FlowStepProjector.ToLifecycleRow(r.Evt)).ToList();
|
||||
var lifecycle = rows.Select(r => r.Row).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;
|
||||
: terminal ? (int?)Math.Min(int.MaxValue, Math.Max(0, Math.Round((latest.OccurredAtUtc - first.OccurredAtUtc).TotalMilliseconds))) : null;
|
||||
|
||||
yield return first with
|
||||
{
|
||||
@@ -237,8 +286,10 @@ public static class FlowGraphBuilder
|
||||
_ => 7,
|
||||
};
|
||||
|
||||
/// <summary>Central ↔ site only; a hop onto the External lane is drawn by its own edge style, never as a cluster crossing.</summary>
|
||||
private static bool CrossesBoundary(FlowStep a, FlowStep b)
|
||||
=> (a.Lane == FlowLane.Central) != (b.Lane == FlowLane.Central);
|
||||
=> a.Lane != FlowLane.External && b.Lane != FlowLane.External
|
||||
&& (a.Lane == FlowLane.Central) != (b.Lane == FlowLane.Central);
|
||||
|
||||
/// <summary>A stable id for the synthesized Response node so re-builds of the same events select the same node.</summary>
|
||||
private static Guid DeriveResponseId(Guid inboundEventId)
|
||||
|
||||
@@ -76,8 +76,10 @@ public sealed record FlowLifecycleRow(
|
||||
|
||||
/// <summary>
|
||||
/// One node of the flow graph — a central-side projection of a <c>ScriptTrace</c> /
|
||||
/// <c>ApiInbound.InboundRequest</c> <see cref="ZB.MOM.WW.Audit.AuditEvent"/>. Never serialized
|
||||
/// to the wire; the wire carries the audit events themselves (design §5.4).
|
||||
/// <c>ApiInbound.InboundRequest</c> <see cref="ZB.MOM.WW.Audit.AuditEvent"/>, or of one outbound
|
||||
/// operation's <c>ApiOutbound</c> / <c>DbOutbound</c> / <c>Notification</c> rows folded together
|
||||
/// (<see cref="Lifecycle"/>; outbound-calls plan 2026-09-06 D3). Never serialized to the wire;
|
||||
/// the wire carries the audit events themselves (design §5.4).
|
||||
/// </summary>
|
||||
public sealed record FlowStep(
|
||||
Guid EventId,
|
||||
|
||||
@@ -164,8 +164,8 @@ public static class FlowStepProjector
|
||||
{
|
||||
var op = opEl.GetString();
|
||||
int? rows = null;
|
||||
if (root.TryGetProperty("rowsAffected", out var ra) && ra.ValueKind == JsonValueKind.Number) rows = ra.GetInt32();
|
||||
else if (root.TryGetProperty("rowsReturned", out var rr) && rr.ValueKind == JsonValueKind.Number) rows = rr.GetInt32();
|
||||
if (root.TryGetProperty("rowsAffected", out var ra) && ra.ValueKind == JsonValueKind.Number && ra.TryGetInt32(out var affected)) rows = affected;
|
||||
else if (root.TryGetProperty("rowsReturned", out var rr) && rr.ValueKind == JsonValueKind.Number && rr.TryGetInt32(out var returned)) rows = returned;
|
||||
if (rows is { } n && n >= 0)
|
||||
return $"{op} · {n} {(n == 1 ? "row" : "rows")}";
|
||||
return op;
|
||||
|
||||
Reference in New Issue
Block a user