feat(inboundapi): mint inbound ExecutionId early, carry it as RouteToCallRequest.ParentExecutionId
This commit is contained in:
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ScadaLink.Commons.Entities.InboundApi;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Messages.InboundApi;
|
||||
|
||||
namespace ScadaLink.InboundAPI.Tests;
|
||||
|
||||
@@ -427,6 +428,71 @@ public class InboundScriptExecutorTests
|
||||
Assert.Contains("whatever", result.ResultJson!);
|
||||
}
|
||||
|
||||
// --- Audit Log #23 (ParentExecutionId, T3): the inbound request's
|
||||
// ExecutionId is threaded through ExecuteAsync onto routed calls ---
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithParentExecutionId_RoutedCallCarriesItAsParentExecutionId()
|
||||
{
|
||||
// The endpoint hands ExecuteAsync the inbound request's ExecutionId; a
|
||||
// routed Route.To(...).Call(...) inside the script must stamp that id onto
|
||||
// the RouteToCallRequest as ParentExecutionId.
|
||||
var inboundExecutionId = Guid.NewGuid();
|
||||
|
||||
var locator = Substitute.For<IInstanceLocator>();
|
||||
locator.GetSiteIdForInstanceAsync("inst-1", Arg.Any<CancellationToken>()).Returns("SiteA");
|
||||
var router = Substitute.For<IInstanceRouter>();
|
||||
RouteToCallRequest? captured = null;
|
||||
router.RouteToCallAsync("SiteA", Arg.Do<RouteToCallRequest>(r => captured = r), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToCallResponse(
|
||||
((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));
|
||||
var route = new RouteHelper(locator, router);
|
||||
|
||||
var method = new ApiMethod("routes", "return 1;") { Id = 1, TimeoutSeconds = 10 };
|
||||
_executor.RegisterHandler("routes", async ctx =>
|
||||
{
|
||||
await ctx.Route.To("inst-1").Call("doWork");
|
||||
return 1;
|
||||
});
|
||||
|
||||
var result = await _executor.ExecuteAsync(
|
||||
method, new Dictionary<string, object?>(), route, TimeSpan.FromSeconds(10),
|
||||
parentExecutionId: inboundExecutionId);
|
||||
|
||||
Assert.True(result.Success, result.ErrorMessage);
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal(inboundExecutionId, captured!.ParentExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithoutParentExecutionId_RoutedCallHasNullParentExecutionId()
|
||||
{
|
||||
// ExecuteAsync called without a parent execution id (the default) routes
|
||||
// calls with ParentExecutionId null.
|
||||
var locator = Substitute.For<IInstanceLocator>();
|
||||
locator.GetSiteIdForInstanceAsync("inst-1", Arg.Any<CancellationToken>()).Returns("SiteA");
|
||||
var router = Substitute.For<IInstanceRouter>();
|
||||
RouteToCallRequest? captured = null;
|
||||
router.RouteToCallAsync("SiteA", Arg.Do<RouteToCallRequest>(r => captured = r), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToCallResponse(
|
||||
((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));
|
||||
var route = new RouteHelper(locator, router);
|
||||
|
||||
var method = new ApiMethod("routes2", "return 1;") { Id = 1, TimeoutSeconds = 10 };
|
||||
_executor.RegisterHandler("routes2", async ctx =>
|
||||
{
|
||||
await ctx.Route.To("inst-1").Call("doWork");
|
||||
return 1;
|
||||
});
|
||||
|
||||
var result = await _executor.ExecuteAsync(
|
||||
method, new Dictionary<string, object?>(), route, TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.True(result.Success, result.ErrorMessage);
|
||||
Assert.NotNull(captured);
|
||||
Assert.Null(captured!.ParentExecutionId);
|
||||
}
|
||||
|
||||
private sealed class CompileLogCounter
|
||||
{
|
||||
public int CompilationFailures;
|
||||
|
||||
@@ -395,6 +395,78 @@ public class AuditWriteMiddlewareTests
|
||||
Assert.NotEqual(writer.Events[0].ExecutionId, writer.Events[1].ExecutionId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// ParentExecutionId — Audit Log #23 (ParentExecutionId feature, T3): the
|
||||
// inbound request's ExecutionId is minted ONCE, early, and stashed on
|
||||
// HttpContext.Items so the endpoint handler can carry it onto the routed
|
||||
// RouteToCallRequest as ParentExecutionId. The inbound row that the
|
||||
// middleware itself emits stays top-level — its own ParentExecutionId is
|
||||
// NEVER set.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task InboundExecutionId_IsStashedOnHttpItems_BeforeEndpointRuns()
|
||||
{
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
object? observedDuringHandler = null;
|
||||
var mw = CreateMiddleware(hc =>
|
||||
{
|
||||
// The endpoint handler must be able to read the early-minted id —
|
||||
// it is stashed before _next so a downstream reader sees it.
|
||||
hc.Items.TryGetValue(AuditWriteMiddleware.InboundExecutionIdItemKey, out observedDuringHandler);
|
||||
hc.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var stashed = Assert.IsType<Guid>(observedDuringHandler);
|
||||
Assert.NotEqual(Guid.Empty, stashed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InboundRow_ExecutionId_Equals_TheEarlyMintedStashedId()
|
||||
{
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
Guid stashedDuringHandler = Guid.Empty;
|
||||
var mw = CreateMiddleware(hc =>
|
||||
{
|
||||
stashedDuringHandler =
|
||||
(Guid)hc.Items[AuditWriteMiddleware.InboundExecutionIdItemKey]!;
|
||||
hc.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
// The inbound audit row's ExecutionId must be the SAME id minted early
|
||||
// and shared with the endpoint handler — not a second, late mint.
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.Equal(stashedDuringHandler, evt.ExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InboundRow_OwnParentExecutionId_StaysNull()
|
||||
{
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
var mw = CreateMiddleware(_ =>
|
||||
{
|
||||
ctx.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
// The inbound request is itself top-level — only the spawn id flows
|
||||
// OUT on RouteToCallRequest. The inbound row's own ParentExecutionId
|
||||
// is never set.
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.Null(evt.ParentExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DurationMs_IsRecorded()
|
||||
{
|
||||
|
||||
@@ -233,6 +233,71 @@ public class RouteHelperTests
|
||||
Assert.Equal(explicitCts.Token, seen);
|
||||
}
|
||||
|
||||
// --- Audit Log #23 (ParentExecutionId, T3): a routed call carries the
|
||||
// inbound request's ExecutionId as RouteToCallRequest.ParentExecutionId ---
|
||||
|
||||
[Fact]
|
||||
public async Task Call_WithoutParentExecutionId_LeavesParentExecutionIdNull()
|
||||
{
|
||||
// A RouteHelper not bound to an inbound execution id (e.g. the Central UI
|
||||
// sandbox path) builds requests with ParentExecutionId null.
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
RouteToCallRequest? captured = null;
|
||||
_router.RouteToCallAsync("SiteA", Arg.Do<RouteToCallRequest>(r => captured = r), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToCallResponse(
|
||||
((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));
|
||||
|
||||
await CreateHelper().To("inst-1").Call("doWork");
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Null(captured!.ParentExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Call_WithParentExecutionId_CarriesItOnRouteToCallRequest()
|
||||
{
|
||||
// A RouteHelper bound to the inbound request's ExecutionId must stamp that
|
||||
// id onto the routed RouteToCallRequest so the site script records it as
|
||||
// its ParentExecutionId.
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
var inboundExecutionId = Guid.NewGuid();
|
||||
RouteToCallRequest? captured = null;
|
||||
_router.RouteToCallAsync("SiteA", Arg.Do<RouteToCallRequest>(r => captured = r), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToCallResponse(
|
||||
((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));
|
||||
|
||||
var bound = CreateHelper().WithParentExecutionId(inboundExecutionId);
|
||||
await bound.To("inst-1").Call("doWork");
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal(inboundExecutionId, captured!.ParentExecutionId);
|
||||
// ParentExecutionId is a separate concern from the per-op CorrelationId —
|
||||
// the helper still mints its own routed-call correlation id.
|
||||
Assert.True(Guid.TryParse(captured.CorrelationId, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WithParentExecutionId_PreservesDeadlineToken()
|
||||
{
|
||||
// The two builder methods compose — binding a parent execution id must
|
||||
// not drop a previously-bound deadline token.
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
using var deadline = new CancellationTokenSource();
|
||||
CancellationToken seen = default;
|
||||
RouteToCallRequest? captured = null;
|
||||
_router.RouteToCallAsync("SiteA", Arg.Do<RouteToCallRequest>(r => captured = r), Arg.Do<CancellationToken>(t => seen = t))
|
||||
.Returns(ci => new RouteToCallResponse(
|
||||
((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));
|
||||
|
||||
var bound = CreateHelper()
|
||||
.WithDeadline(deadline.Token)
|
||||
.WithParentExecutionId(Guid.NewGuid());
|
||||
await bound.To("inst-1").Call("doWork");
|
||||
|
||||
Assert.Equal(deadline.Token, seen);
|
||||
Assert.NotNull(captured!.ParentExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAttributes_WithNoExplicitToken_InheritsMethodDeadlineToken()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user