feat(flow-debugger): carry site-side trace truncation on the route reply (ScadaBridge#54)
ScriptCallResult and RouteToCallResponse gain trailing Truncated/LogsTruncated, filled from the site FlowTraceCollector after Complete(); RouteToCallResponseDto fields 8/9 carry them (sitecommand regen). FlowDebugRunner ORs them into FlowDebugRunResult on the script entry, and on the method entry through a new optional truncation sink on RouteHelper.WithTraceSink (carried through every With* clone). Previously a site run whose collector dropped rows came back as a silently short list. Older sites send false. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -203,7 +203,10 @@ internal static class SiteCommandSamples
|
||||
|
||||
// ── Route replies ──
|
||||
Add(new RouteToCallResponse("corr-12", true, 17L, null, T1, G1, TraceSamples()),
|
||||
new RouteToCallResponse("corr-12", false, null, "script faulted", T2));
|
||||
new RouteToCallResponse("corr-12", false, null, "script faulted", T2),
|
||||
// Gitea #54: fields 8/9 — the site collector's truncation flags.
|
||||
new RouteToCallResponse("corr-12", true, 17L, null, T1, G1, TraceSamples(), Truncated: true, LogsTruncated: false),
|
||||
new RouteToCallResponse("corr-12", true, 17L, null, T1, G1, TraceSamples(), Truncated: false, LogsTruncated: true));
|
||||
Add(new RouteToGetAttributesResponse("corr-13", Parameters(), true, null, T1),
|
||||
new RouteToGetAttributesResponse("corr-13", new Dictionary<string, object?>(), false, "no instance", T2));
|
||||
Add(new RouteToSetAttributesResponse("corr-14", true, null, T1),
|
||||
|
||||
@@ -130,6 +130,45 @@ public class RouteHelperTraceTests
|
||||
Assert.Same(siteEvents[1], sunk[2]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #54: a routed reply carrying the site collector's truncation flags is forwarded to the
|
||||
/// truncation sink, and the sink survives the helper's clone chain (WithDeadline et al.).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task TruncationSink_ReceivesTheReplysSiteTruncationFlags_ThroughClones()
|
||||
{
|
||||
var child = Guid.NewGuid();
|
||||
_router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, true, null, null,
|
||||
DateTimeOffset.UtcNow, child, [SiteEvent(child)], Truncated: false, LogsTruncated: true));
|
||||
var reports = new List<(bool, bool)>();
|
||||
|
||||
await Helper()
|
||||
.WithTraceSink(_ => { }, (t, lt) => reports.Add((t, lt)))
|
||||
.WithTrace(true)
|
||||
.WithDeadline(CancellationToken.None)
|
||||
.WithSiteScope(null)
|
||||
.To("inst-1").Call("doWork");
|
||||
|
||||
Assert.Equal([(false, true)], reports);
|
||||
}
|
||||
|
||||
/// <summary>Gitea #54 control: an untruncated reply does not call the truncation sink.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task TruncationSink_IsNotCalled_ForAnUntruncatedReply()
|
||||
{
|
||||
_router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, true, null, null,
|
||||
DateTimeOffset.UtcNow));
|
||||
var calls = 0;
|
||||
|
||||
await Helper().WithTraceSink(_ => { }, (_, _) => calls++).To("inst-1").Call("doWork");
|
||||
|
||||
Assert.Equal(0, calls);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a routing failure writes a failed row and still throws.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
|
||||
@@ -307,6 +307,38 @@ public class FlowDebugRunnerTests
|
||||
Assert.Contains(_central, e => e.AsRow().Kind == AuditKind.RouteCall);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #54: on the method entry the routed reply reaches the runner through RouteHelper's
|
||||
/// sink, so the site collector's truncation flags must ride that path too.
|
||||
/// </summary>
|
||||
/// <param name="truncated">The site's step-row truncation flag.</param>
|
||||
/// <param name="logsTruncated">The site's log-row truncation flag.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(false, true)]
|
||||
[InlineData(false, false)]
|
||||
public async Task RunMethod_OrsTheRoutedSitesTruncationFlagsIntoTheResult(bool truncated, bool logsTruncated)
|
||||
{
|
||||
var method = new ApiMethod("M", "x") { Id = 7, TimeoutSeconds = 5 };
|
||||
_apiRepo.GetApiMethodByIdAsync(7, 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,
|
||||
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], truncated, logsTruncated));
|
||||
_executor.RegisterHandler("M", ctx => ctx.Route.To("inst").Call("work"));
|
||||
|
||||
var result = await Runner().RunMethodAsync(_services.BuildServiceProvider(),
|
||||
new FlowDebugRunMethodCommand(7, null), Deployer, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Completed, result.Error);
|
||||
Assert.Equal(truncated, result.Truncated);
|
||||
Assert.Equal(logsTruncated, result.LogsTruncated);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a site clock behind central still returns the sink events in emission order.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
@@ -908,6 +940,35 @@ public class FlowDebugRunnerTests
|
||||
Assert.Equal("OtherScript", result.Events[1].Target);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #54: a call-kind script run whose SITE collector dropped rows reports truncation on
|
||||
/// the result, even though central's own collector saw only the (short) list the site sent.
|
||||
/// </summary>
|
||||
/// <param name="truncated">The site's step-row truncation flag.</param>
|
||||
/// <param name="logsTruncated">The site's log-row truncation flag.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(false, true)]
|
||||
[InlineData(false, false)]
|
||||
public async Task RunScript_CallKind_OrsTheSitesTruncationFlagsIntoTheResult(bool truncated, bool logsTruncated)
|
||||
{
|
||||
WireScript(CallScript("Manual", """{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}""", timeout: 5));
|
||||
var child = Guid.NewGuid();
|
||||
var siteRun = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
|
||||
executionId: child, target: "Manual");
|
||||
_router.RouteToCallAsync("site-a", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, true, 42, null,
|
||||
DateTimeOffset.UtcNow, child, [siteRun], truncated, logsTruncated));
|
||||
|
||||
var result = await Runner().RunScriptAsync(_services.BuildServiceProvider(),
|
||||
new FlowDebugRunScriptCommand(5, "Manual", Json("{\"n\":3}"), null), Deployer, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Completed, result.Error);
|
||||
Assert.Equal(truncated, result.Truncated);
|
||||
Assert.Equal(logsTruncated, result.LogsTruncated);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a call-kind script run routes a traced call and returns the root route-call row followed by the site rows.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
|
||||
+34
-5
@@ -46,9 +46,9 @@ public class DeploymentManagerRouteTraceTests : TestKit, IDisposable
|
||||
TestLocalDb.DeleteFiles(path);
|
||||
}
|
||||
|
||||
private IActorRef CreateDeploymentManager() =>
|
||||
private IActorRef CreateDeploymentManager(SiteRuntimeOptions? options = null) =>
|
||||
ActorOf(Props.Create(() => new DeploymentManagerActor(
|
||||
_storage, _compilationService, _sharedScriptLibrary, null, new SiteRuntimeOptions(),
|
||||
_storage, _compilationService, _sharedScriptLibrary, null, options ?? new SiteRuntimeOptions(),
|
||||
NullLogger<DeploymentManagerActor>.Instance,
|
||||
dclManager: null, healthCollector: null, serviceProvider: null, loggerFactory: null, configFetcher: null)));
|
||||
|
||||
@@ -56,12 +56,17 @@ public class DeploymentManagerRouteTraceTests : TestKit, IDisposable
|
||||
{
|
||||
InstanceUniqueName = instance,
|
||||
Attributes = [new ResolvedAttribute { CanonicalName = "Mode", Value = "Auto", DataType = "String" }],
|
||||
Scripts = [new ResolvedScript { CanonicalName = "DoWork", Code = "await Instance.SetAttribute(\"Mode\", \"Manual\"); return 7;" }],
|
||||
Scripts =
|
||||
[
|
||||
new ResolvedScript { CanonicalName = "DoWork", Code = "await Instance.SetAttribute(\"Mode\", \"Manual\"); return 7;" },
|
||||
// Gitea #54: more log lines than the inline collector's low-priority share (450).
|
||||
new ResolvedScript { CanonicalName = "Chatty", Code = "for (var i = 0; i < 600; i++) Log.Info(\"line \" + i); return 1;" },
|
||||
],
|
||||
});
|
||||
|
||||
private async Task<IActorRef> DeployedManager(string instance)
|
||||
private async Task<IActorRef> DeployedManager(string instance, SiteRuntimeOptions? options = null)
|
||||
{
|
||||
var actor = CreateDeploymentManager();
|
||||
var actor = CreateDeploymentManager(options);
|
||||
await Task.Delay(500);
|
||||
actor.Tell(new DeployInstanceCommand("dep", instance, "sha256:x", ConfigJson(instance), "admin", DateTimeOffset.UtcNow));
|
||||
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
|
||||
@@ -96,6 +101,30 @@ public class DeploymentManagerRouteTraceTests : TestKit, IDisposable
|
||||
Assert.All(rows, r => Assert.Equal(response.ChildExecutionId, r.ExecutionId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #54: a traced routed run whose inline collector dropped log rows carries
|
||||
/// <c>LogsTruncated</c> on the <see cref="RouteToCallResponse"/>, so central can label the short
|
||||
/// site lane; a run that fit reports neither flag.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RouteCall_WithTrace_CarriesTheSiteCollectorsTruncationFlags()
|
||||
{
|
||||
var actor = await DeployedManager("ChattyPump", new SiteRuntimeOptions { MaxLogLinesPerRun = 1000 });
|
||||
|
||||
actor.Tell(new RouteToCallRequest("rc-t", "ChattyPump", "Chatty", null, DateTimeOffset.UtcNow, Trace: true));
|
||||
var chatty = ExpectMsg<RouteToCallResponse>(TimeSpan.FromSeconds(10));
|
||||
Assert.True(chatty.Success, chatty.ErrorMessage);
|
||||
Assert.True(chatty.LogsTruncated);
|
||||
Assert.False(chatty.Truncated);
|
||||
|
||||
actor.Tell(new RouteToCallRequest("rc-q", "ChattyPump", "DoWork", null, DateTimeOffset.UtcNow, Trace: true));
|
||||
var quiet = ExpectMsg<RouteToCallResponse>(TimeSpan.FromSeconds(10));
|
||||
Assert.True(quiet.Success, quiet.ErrorMessage);
|
||||
Assert.False(quiet.LogsTruncated);
|
||||
Assert.False(quiet.Truncated);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an untraced route call returns the execution id but no trace events.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
|
||||
@@ -161,4 +161,38 @@ public class ScriptRunLauncherTraceTests : TestKit, IDisposable
|
||||
Assert.All(logs, e => Assert.True(rows.ToList().IndexOf(e) < anchorIndex));
|
||||
Assert.Equal(logs.Count, anchorIndex);
|
||||
}
|
||||
|
||||
// ── (d) the reply reports the site collector's truncation (Gitea #54) ────────
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a traced run whose inline collector dropped log-line rows says so on the
|
||||
/// reply (<c>LogsTruncated</c>), instead of handing the caller a silently short list.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TracedRun_WhoseCollectorDroppedLogRows_ReportsLogsTruncated()
|
||||
{
|
||||
// 600 lines is under the per-run line cap set here (1000) but over the collector's
|
||||
// low-priority event share (DefaultMaxEvents - LowPriorityReserveEvents = 450).
|
||||
var result = RunTraced(
|
||||
"for (var i = 0; i < 600; i++) Log.Info(\"line \" + i); return null;",
|
||||
Options(maxLogLines: 1000));
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.LogsTruncated);
|
||||
Assert.False(result.Truncated);
|
||||
Assert.True(result.TraceEvents!.Count < 600);
|
||||
// Step rows still fit: the ScriptRun anchor is present.
|
||||
ScriptRunRow(result);
|
||||
}
|
||||
|
||||
/// <summary>Positive control: a small traced run reports neither truncation flag.</summary>
|
||||
[Fact]
|
||||
public void TracedRun_WithinTheCollectorCaps_ReportsNoTruncation()
|
||||
{
|
||||
var result = RunTraced("Log.Info(\"one\"); return null;", Options());
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.False(result.LogsTruncated);
|
||||
Assert.False(result.Truncated);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user