fix(central-ui): Flow Debugger outcome refresh merges rows by EventId instead of replacing the graph
Found in a browser Live run on the rig: the run drew API, DB and Notify nodes from its inline rows; the 5 s pending-refresh re-read the tree through Replay before the site's cached-call telemetry (12 s) had reached central, swapped the graph wholesale, and the resolved DB node vanished (7 -> 6 steps) until a manual refresh. Polling had already stopped because nothing was Pending. MergeRefreshedGraph now unions the re-read rows into the run's known rows by EventId (fresh copy wins), stores the union back on _raw and rebuilds through FlowGraphBuilder, so a refresh only ever adds nodes. The union is exact: the inline rows are the same AuditEvent objects the site writer persists (FlowTraceCollector). Tests: new PendingRefresh_KeepsStepsTheLiveRunAlreadyHad_WhenReplayLagsBehindTelemetry (fails on the old code). Two existing fixtures were internally inconsistent and only passed under the old replace semantics: ParkedThenResolved built its Resolved outcome with the Parked raw rows, and OutboundOutcome minted fresh event/correlation ids per call, so no row could ever match across live and replay. Both now share rows per run. Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
@@ -252,7 +252,7 @@ public class FlowDebuggerPageTests : BunitContext
|
||||
occurredAtUtc: t0.AddSeconds(9), target: "Delmia.Ack", correlationId: corr, executionId: child, parentExecutionId: root,
|
||||
sourceSiteId: "site-a", sourceInstanceId: "Sim01"),
|
||||
};
|
||||
var resolved = new FlowDebugOutcome(FlowGraphBuilder.Build(later), raw, null, null);
|
||||
var resolved = new FlowDebugOutcome(FlowGraphBuilder.Build(later), raw with { Events = later }, null, null);
|
||||
return (parked, resolved, submit.EventId);
|
||||
}
|
||||
|
||||
@@ -345,13 +345,35 @@ public class FlowDebuggerPageTests : BunitContext
|
||||
|
||||
// ---- Outbound calls (2026-09-06 plan, Task 9): pending refresh after a Live run ----------
|
||||
|
||||
/// <summary>Inbound → route → site run → cached API call; the call is Pending unless <paramref name="resolved"/>.</summary>
|
||||
/// <summary>
|
||||
/// Inbound → route → site run → cached API call; the call is Pending unless <paramref name="resolved"/>.
|
||||
/// Both outcomes of one <paramref name="root"/> share their rows (same event/correlation ids), as the
|
||||
/// real system does: the inline Live rows ARE the audit rows Replay later reads back.
|
||||
/// </summary>
|
||||
private static FlowDebugOutcome OutboundOutcome(Guid root, bool resolved)
|
||||
{
|
||||
var child = Guid.NewGuid();
|
||||
var corr = Guid.NewGuid();
|
||||
var t0 = DateTime.UtcNow.AddSeconds(-10);
|
||||
var events = new List<AuditEvent>
|
||||
var (events, child, corr, t0) = OutboundBase.GetOrAdd(root, r =>
|
||||
{
|
||||
var child = Guid.NewGuid();
|
||||
var corr = Guid.NewGuid();
|
||||
var t0 = DateTime.UtcNow.AddSeconds(-10);
|
||||
return (BuildOutboundBase(r, child, corr, t0), child, corr, t0);
|
||||
});
|
||||
events = new List<AuditEvent>(events);
|
||||
if (resolved)
|
||||
events.Add(ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.CachedResolve, AuditStatus.Delivered,
|
||||
occurredAtUtc: t0.AddSeconds(4), target: "Delmia.Ack", correlationId: corr, executionId: child, parentExecutionId: root,
|
||||
sourceSiteId: "site-a", sourceInstanceId: "Sim01"));
|
||||
|
||||
var raw = new FlowDebugRunResult(root, true, null, null, events, false, t0, t0.AddSeconds(1));
|
||||
return new FlowDebugOutcome(FlowGraphBuilder.Build(events), raw, null, null);
|
||||
}
|
||||
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<Guid, (List<AuditEvent> Events, Guid Child, Guid Corr, DateTime T0)> OutboundBase = new();
|
||||
|
||||
private static List<AuditEvent> BuildOutboundBase(Guid root, Guid child, Guid corr, DateTime t0)
|
||||
{
|
||||
return new List<AuditEvent>
|
||||
{
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
|
||||
occurredAtUtc: t0, target: "POST /api/DelmiaRecipeDownload", executionId: root, durationMs: 100, responseSummary: "{}"),
|
||||
@@ -365,13 +387,6 @@ public class FlowDebuggerPageTests : BunitContext
|
||||
occurredAtUtc: t0.AddMilliseconds(3), target: "Delmia.Ack", correlationId: corr, executionId: child, parentExecutionId: root,
|
||||
sourceSiteId: "site-a", sourceInstanceId: "Sim01"),
|
||||
};
|
||||
if (resolved)
|
||||
events.Add(ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.CachedResolve, AuditStatus.Delivered,
|
||||
occurredAtUtc: t0.AddSeconds(4), target: "Delmia.Ack", correlationId: corr, executionId: child, parentExecutionId: root,
|
||||
sourceSiteId: "site-a", sourceInstanceId: "Sim01"));
|
||||
|
||||
var raw = new FlowDebugRunResult(root, true, null, null, events, false, t0, t0.AddSeconds(1));
|
||||
return new FlowDebugOutcome(FlowGraphBuilder.Build(events), raw, null, null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -396,6 +411,79 @@ public class FlowDebuggerPageTests : BunitContext
|
||||
Assert.NotNull(cut.Find("button[data-test='flow-refresh']"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Live run whose inline site rows already resolved a cached DB write, with a notification still
|
||||
/// Pending; <paramref name="withDbRows"/>=false models the Replay read taken before that DB
|
||||
/// telemetry reached central (observed live on the rig 2026-09-06: cached-call telemetry landed
|
||||
/// 12 s after the run, the first poll at 5 s had none of it).
|
||||
/// </summary>
|
||||
private static FlowDebugOutcome DbAndNotifyOutcome(Guid root, Guid child, Guid dbCorr, Guid notifyCorr, DateTime t0, bool withDbRows, bool notifyDelivered)
|
||||
{
|
||||
var events = new List<AuditEvent>
|
||||
{
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
|
||||
occurredAtUtc: t0, target: "POST /api/FlowOutboundDemo", executionId: root, durationMs: 100, responseSummary: "{}"),
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
|
||||
occurredAtUtc: t0.AddMilliseconds(1), target: "Sim01.OutboundDemo", executionId: root, durationMs: 90,
|
||||
extra: new ScriptTraceExtra { ChildExecutionId = child }.ToJson()),
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
|
||||
occurredAtUtc: t0.AddMilliseconds(2), target: "OutboundDemo", executionId: child, parentExecutionId: root,
|
||||
sourceSiteId: "site-a", sourceInstanceId: "Sim01", durationMs: 80),
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.Notification, AuditKind.NotifySend, AuditStatus.Submitted,
|
||||
occurredAtUtc: t0.AddMilliseconds(10), target: "FlowDemo", correlationId: notifyCorr, executionId: child, parentExecutionId: root,
|
||||
sourceSiteId: "site-a", sourceInstanceId: "Sim01"),
|
||||
};
|
||||
if (withDbRows)
|
||||
{
|
||||
events.Add(ScadaBridgeAuditEventFactory.Create(AuditChannel.DbOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted,
|
||||
occurredAtUtc: t0.AddMilliseconds(3), target: "RigDb", correlationId: dbCorr, executionId: child, parentExecutionId: root,
|
||||
sourceSiteId: "site-a", sourceInstanceId: "Sim01"));
|
||||
events.Add(ScadaBridgeAuditEventFactory.Create(AuditChannel.DbOutbound, AuditKind.CachedResolve, AuditStatus.Delivered,
|
||||
occurredAtUtc: t0.AddMilliseconds(5), target: "RigDb", correlationId: dbCorr, executionId: child, parentExecutionId: root,
|
||||
sourceSiteId: "site-a", sourceInstanceId: "Sim01"));
|
||||
}
|
||||
if (notifyDelivered)
|
||||
events.Add(ScadaBridgeAuditEventFactory.Create(AuditChannel.Notification, AuditKind.NotifyDeliver, AuditStatus.Delivered,
|
||||
occurredAtUtc: t0.AddSeconds(1), target: "FlowDemo", correlationId: notifyCorr, executionId: child, parentExecutionId: root,
|
||||
sourceSiteId: "site-a", sourceInstanceId: "Sim01"));
|
||||
|
||||
var raw = new FlowDebugRunResult(root, true, null, null, events, false, t0, t0.AddSeconds(1));
|
||||
return new FlowDebugOutcome(FlowGraphBuilder.Build(events), raw, null, null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingRefresh_KeepsStepsTheLiveRunAlreadyHad_WhenReplayLagsBehindTelemetry()
|
||||
{
|
||||
RegisterServices();
|
||||
var root = Guid.NewGuid();
|
||||
var child = Guid.NewGuid();
|
||||
var dbCorr = Guid.NewGuid();
|
||||
var notifyCorr = Guid.NewGuid();
|
||||
var t0 = DateTime.UtcNow.AddSeconds(-10);
|
||||
var live = DbAndNotifyOutcome(root, child, dbCorr, notifyCorr, t0, withDbRows: true, notifyDelivered: false);
|
||||
var lagging = DbAndNotifyOutcome(root, child, dbCorr, notifyCorr, t0, withDbRows: false, notifyDelivered: true);
|
||||
// Sanity: the two graphs disagree exactly on the DB node.
|
||||
Assert.Contains(live.Graph!.Steps, s => s.Kind == FlowStepKind.DbCall);
|
||||
Assert.DoesNotContain(lagging.Graph!.Steps, s => s.Kind == FlowStepKind.DbCall);
|
||||
|
||||
_flow.RunMethodAsync(4, Arg.Any<JsonElement?>(), Arg.Any<CancellationToken>()).Returns(live);
|
||||
_flow.ReplayAsync(root, Arg.Any<CancellationToken>()).Returns(lagging);
|
||||
var cut = Render<FlowDebuggerPage>();
|
||||
cut.Instance.Delay = (_, _) => Task.CompletedTask;
|
||||
cut.Find("select[data-test='flow-method-select']").Change("4");
|
||||
cut.WaitForElement("input[data-test='flow-input-MachineCode']");
|
||||
|
||||
cut.Find("button[data-test='flow-run']").Click();
|
||||
|
||||
// The poll resolves the notification (nothing Pending → polling stops) ...
|
||||
cut.WaitForAssertion(() => _flow.Received(1).ReplayAsync(root, Arg.Any<CancellationToken>()));
|
||||
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll("[data-test='flow-pending-count']")));
|
||||
// ... but must NOT drop the DB node the live run already drew: a refresh only ever adds rows.
|
||||
Assert.NotEmpty(cut.FindAll("g[data-test='flow-node'][data-kind='DbCall']"));
|
||||
Assert.NotEmpty(cut.FindAll("g[data-test='flow-node'][data-kind='Notify']"));
|
||||
Assert.Contains(cut.FindAll("[data-test='flow-node-pill']"), p => p.TextContent.Trim() == "Ok");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LiveRun_WithNoPendingStep_NeverPolls()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user