feat(inboundapi): mint inbound ExecutionId early, carry it as RouteToCallRequest.ParentExecutionId
This commit is contained in:
@@ -4,12 +4,21 @@ namespace ScadaLink.Commons.Messages.InboundApi;
|
|||||||
/// Request routed from Inbound API to a site to invoke a script on an instance.
|
/// Request routed from Inbound API to a site to invoke a script on an instance.
|
||||||
/// Used by Route.To("instanceCode").Call("scriptName", params).
|
/// Used by Route.To("instanceCode").Call("scriptName", params).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="ParentExecutionId">
|
||||||
|
/// Audit Log #23 (ParentExecutionId): the spawning execution's <c>ExecutionId</c>
|
||||||
|
/// — for an inbound-API-routed call this is the inbound request's per-request
|
||||||
|
/// execution id. The site records it as the routed script execution's
|
||||||
|
/// <c>ParentExecutionId</c> so a spawned execution points back at its spawner.
|
||||||
|
/// Additive trailing member — null for requests built before the field existed
|
||||||
|
/// or for routed calls with no spawning execution (e.g. the Central UI sandbox).
|
||||||
|
/// </param>
|
||||||
public record RouteToCallRequest(
|
public record RouteToCallRequest(
|
||||||
string CorrelationId,
|
string CorrelationId,
|
||||||
string InstanceUniqueName,
|
string InstanceUniqueName,
|
||||||
string ScriptName,
|
string ScriptName,
|
||||||
IReadOnlyDictionary<string, object?>? Parameters,
|
IReadOnlyDictionary<string, object?>? Parameters,
|
||||||
DateTimeOffset Timestamp);
|
DateTimeOffset Timestamp,
|
||||||
|
Guid? ParentExecutionId = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Response from a Route.To() call.
|
/// Response from a Route.To() call.
|
||||||
|
|||||||
@@ -92,8 +92,21 @@ public static class EndpointExtensions
|
|||||||
? TimeSpan.FromSeconds(method.TimeoutSeconds)
|
? TimeSpan.FromSeconds(method.TimeoutSeconds)
|
||||||
: options.DefaultMethodTimeout;
|
: options.DefaultMethodTimeout;
|
||||||
|
|
||||||
|
// Audit Log #23 (ParentExecutionId): the inbound request's per-request
|
||||||
|
// ExecutionId was minted early by AuditWriteMiddleware and stashed on
|
||||||
|
// HttpContext.Items. Thread it into the executor so a routed
|
||||||
|
// Route.To(...).Call(...) carries it as RouteToCallRequest.ParentExecutionId
|
||||||
|
// — the spawned site script execution points back at this inbound request.
|
||||||
|
var parentExecutionId =
|
||||||
|
httpContext.Items.TryGetValue(
|
||||||
|
AuditWriteMiddleware.InboundExecutionIdItemKey, out var stashedExecutionId)
|
||||||
|
&& stashedExecutionId is Guid inboundExecutionId
|
||||||
|
? inboundExecutionId
|
||||||
|
: (Guid?)null;
|
||||||
|
|
||||||
var scriptResult = await executor.ExecuteAsync(
|
var scriptResult = await executor.ExecuteAsync(
|
||||||
method, paramResult.Parameters, routeHelper, timeout, httpContext.RequestAborted);
|
method, paramResult.Parameters, routeHelper, timeout,
|
||||||
|
httpContext.RequestAborted, parentExecutionId);
|
||||||
|
|
||||||
if (!scriptResult.Success)
|
if (!scriptResult.Success)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Microsoft.CodeAnalysis.CSharp.Scripting;
|
|||||||
using Microsoft.CodeAnalysis.Scripting;
|
using Microsoft.CodeAnalysis.Scripting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ScadaLink.Commons.Entities.InboundApi;
|
using ScadaLink.Commons.Entities.InboundApi;
|
||||||
|
using ScadaLink.Commons.Messages.InboundApi;
|
||||||
using ScadaLink.Commons.Types;
|
using ScadaLink.Commons.Types;
|
||||||
|
|
||||||
namespace ScadaLink.InboundAPI;
|
namespace ScadaLink.InboundAPI;
|
||||||
@@ -156,12 +157,22 @@ public class InboundScriptExecutor
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Executes the script for the given method with the provided context.
|
/// Executes the script for the given method with the provided context.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="parentExecutionId">
|
||||||
|
/// Audit Log #23 (ParentExecutionId): the inbound API request's per-request
|
||||||
|
/// <c>ExecutionId</c> (minted early by <c>AuditWriteMiddleware</c> and stashed
|
||||||
|
/// on <c>HttpContext.Items</c>). When supplied, a routed
|
||||||
|
/// <c>Route.To(...).Call(...)</c> inside the script carries it as
|
||||||
|
/// <see cref="RouteToCallRequest.ParentExecutionId"/> so the spawned site
|
||||||
|
/// script execution points back at this inbound request. Null when the script
|
||||||
|
/// runs outside an inbound API request flow.
|
||||||
|
/// </param>
|
||||||
public async Task<InboundScriptResult> ExecuteAsync(
|
public async Task<InboundScriptResult> ExecuteAsync(
|
||||||
ApiMethod method,
|
ApiMethod method,
|
||||||
IReadOnlyDictionary<string, object?> parameters,
|
IReadOnlyDictionary<string, object?> parameters,
|
||||||
RouteHelper route,
|
RouteHelper route,
|
||||||
TimeSpan timeout,
|
TimeSpan timeout,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default,
|
||||||
|
Guid? parentExecutionId = null)
|
||||||
{
|
{
|
||||||
// InboundAPI-004: keep the timeout source and the request-abort source
|
// InboundAPI-004: keep the timeout source and the request-abort source
|
||||||
// separable. A single linked CTS makes a genuine client disconnect
|
// separable. A single linked CTS makes a genuine client disconnect
|
||||||
@@ -177,7 +188,14 @@ public class InboundScriptExecutor
|
|||||||
// InboundAPI-016: bind the route helper to the method deadline so a
|
// InboundAPI-016: bind the route helper to the method deadline so a
|
||||||
// routed Route.To(...).Call(...) inherits the method-level timeout
|
// routed Route.To(...).Call(...) inherits the method-level timeout
|
||||||
// without the script having to thread the context token by hand.
|
// without the script having to thread the context token by hand.
|
||||||
var context = new InboundScriptContext(parameters, route.WithDeadline(cts.Token), cts.Token);
|
//
|
||||||
|
// Audit Log #23 (ParentExecutionId): also bind the inbound request's
|
||||||
|
// ExecutionId so a routed call carries it as ParentExecutionId — the
|
||||||
|
// spawned site script execution points back at this inbound request.
|
||||||
|
var context = new InboundScriptContext(
|
||||||
|
parameters,
|
||||||
|
route.WithDeadline(cts.Token).WithParentExecutionId(parentExecutionId),
|
||||||
|
cts.Token);
|
||||||
|
|
||||||
if (!_scriptHandlers.TryGetValue(method.Name, out var handler))
|
if (!_scriptHandlers.TryGetValue(method.Name, out var handler))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -59,6 +59,18 @@ public sealed class AuditWriteMiddleware
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const string AuditActorItemKey = "ScadaLink.InboundAPI.AuditActor";
|
public const string AuditActorItemKey = "ScadaLink.InboundAPI.AuditActor";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 (ParentExecutionId): <see cref="HttpContext.Items"/> key under
|
||||||
|
/// which this middleware stashes the inbound request's per-request
|
||||||
|
/// <c>ExecutionId</c> (a <see cref="Guid"/>) at the very start of the request.
|
||||||
|
/// The id is minted ONCE and shared: the endpoint handler reads it to thread it
|
||||||
|
/// onto a routed <c>RouteToCallRequest.ParentExecutionId</c>, and the
|
||||||
|
/// middleware's own inbound audit row uses the same id for its
|
||||||
|
/// <see cref="AuditEvent.ExecutionId"/>. Exposed as a constant so the handler
|
||||||
|
/// and middleware share a single source of truth (no stringly-typed coupling).
|
||||||
|
/// </summary>
|
||||||
|
public const string InboundExecutionIdItemKey = "ScadaLink.InboundAPI.InboundExecutionId";
|
||||||
|
|
||||||
private readonly RequestDelegate _next;
|
private readonly RequestDelegate _next;
|
||||||
private readonly ICentralAuditWriter _auditWriter;
|
private readonly ICentralAuditWriter _auditWriter;
|
||||||
private readonly ILogger<AuditWriteMiddleware> _logger;
|
private readonly ILogger<AuditWriteMiddleware> _logger;
|
||||||
@@ -77,6 +89,17 @@ public sealed class AuditWriteMiddleware
|
|||||||
{
|
{
|
||||||
var sw = Stopwatch.StartNew();
|
var sw = Stopwatch.StartNew();
|
||||||
|
|
||||||
|
// Audit Log #23 (ParentExecutionId): mint the inbound request's per-request
|
||||||
|
// ExecutionId ONCE, here at the start of the request, and stash it on
|
||||||
|
// HttpContext.Items. Two consumers share this single id:
|
||||||
|
// (a) the endpoint handler reads it to thread onto a routed
|
||||||
|
// RouteToCallRequest.ParentExecutionId, so a spawned site script
|
||||||
|
// execution points back at this inbound request;
|
||||||
|
// (b) the inbound audit row this middleware emits uses it as its own
|
||||||
|
// ExecutionId (the row stays top-level — its ParentExecutionId is
|
||||||
|
// never set).
|
||||||
|
ctx.Items[InboundExecutionIdItemKey] = Guid.NewGuid();
|
||||||
|
|
||||||
// Buffer the request body up front so we can both audit it and let the
|
// Buffer the request body up front so we can both audit it and let the
|
||||||
// downstream handler still parse it. EnableBuffering swaps the request
|
// downstream handler still parse it. EnableBuffering swaps the request
|
||||||
// stream for a seekable wrapper that the framework rewinds at the end
|
// stream for a seekable wrapper that the framework rewinds at the end
|
||||||
@@ -145,17 +168,14 @@ public sealed class AuditWriteMiddleware
|
|||||||
OccurredAtUtc = DateTime.UtcNow,
|
OccurredAtUtc = DateTime.UtcNow,
|
||||||
Channel = AuditChannel.ApiInbound,
|
Channel = AuditChannel.ApiInbound,
|
||||||
Kind = kind,
|
Kind = kind,
|
||||||
// Audit Log #23: a fresh per-request execution id so the
|
// Audit Log #23: the per-request execution id minted ONCE at the
|
||||||
// inbound row carries a request identifier (closes the design
|
// start of the request (InvokeAsync) and stashed on
|
||||||
// gap that inbound rows should be correlatable).
|
// HttpContext.Items. The same id is threaded onto a routed
|
||||||
//
|
// RouteToCallRequest.ParentExecutionId by the endpoint handler,
|
||||||
// This id is intentionally request-local: it is NOT bridged to
|
// so an inbound request and the site script it routes to share
|
||||||
// RouteHelper's routed-call correlation id or to
|
// one correlation point. This inbound row stays top-level — its
|
||||||
// HttpContext.TraceIdentifier. Threading an inbound request's
|
// own ParentExecutionId is never set (see below).
|
||||||
// execution id through to the routed script execution (so an
|
ExecutionId = ResolveInboundExecutionId(ctx),
|
||||||
// inbound call and the outbound API/DB rows it triggers share
|
|
||||||
// one id) is a deliberate future follow-up, out of scope here.
|
|
||||||
ExecutionId = Guid.NewGuid(),
|
|
||||||
// CorrelationId is purely the per-operation-lifecycle id; an
|
// CorrelationId is purely the per-operation-lifecycle id; an
|
||||||
// inbound request is a one-shot from the audit row's
|
// inbound request is a one-shot from the audit row's
|
||||||
// perspective with no multi-row operation to correlate.
|
// perspective with no multi-row operation to correlate.
|
||||||
@@ -225,6 +245,24 @@ public sealed class AuditWriteMiddleware
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 (ParentExecutionId): reads the inbound request's per-request
|
||||||
|
/// <c>ExecutionId</c> that <see cref="InvokeAsync"/> minted and stashed on
|
||||||
|
/// <see cref="HttpContext.Items"/> under <see cref="InboundExecutionIdItemKey"/>.
|
||||||
|
/// Falls back to a fresh id only if the slot is somehow absent — the inbound
|
||||||
|
/// audit row must always carry an execution id.
|
||||||
|
/// </summary>
|
||||||
|
private static Guid ResolveInboundExecutionId(HttpContext ctx)
|
||||||
|
{
|
||||||
|
if (ctx.Items.TryGetValue(InboundExecutionIdItemKey, out var stashed)
|
||||||
|
&& stashed is Guid id)
|
||||||
|
{
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Guid.NewGuid();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reads the API key name the endpoint handler stashed on
|
/// Reads the API key name the endpoint handler stashed on
|
||||||
/// <see cref="HttpContext.Items"/> after successful auth. Falls back to
|
/// <see cref="HttpContext.Items"/> after successful auth. Falls back to
|
||||||
|
|||||||
@@ -19,22 +19,25 @@ public class RouteHelper
|
|||||||
private readonly IInstanceLocator _instanceLocator;
|
private readonly IInstanceLocator _instanceLocator;
|
||||||
private readonly IInstanceRouter _instanceRouter;
|
private readonly IInstanceRouter _instanceRouter;
|
||||||
private readonly CancellationToken _deadlineToken;
|
private readonly CancellationToken _deadlineToken;
|
||||||
|
private readonly Guid? _parentExecutionId;
|
||||||
|
|
||||||
public RouteHelper(
|
public RouteHelper(
|
||||||
IInstanceLocator instanceLocator,
|
IInstanceLocator instanceLocator,
|
||||||
IInstanceRouter instanceRouter)
|
IInstanceRouter instanceRouter)
|
||||||
: this(instanceLocator, instanceRouter, CancellationToken.None)
|
: this(instanceLocator, instanceRouter, CancellationToken.None, parentExecutionId: null)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
private RouteHelper(
|
private RouteHelper(
|
||||||
IInstanceLocator instanceLocator,
|
IInstanceLocator instanceLocator,
|
||||||
IInstanceRouter instanceRouter,
|
IInstanceRouter instanceRouter,
|
||||||
CancellationToken deadlineToken)
|
CancellationToken deadlineToken,
|
||||||
|
Guid? parentExecutionId)
|
||||||
{
|
{
|
||||||
_instanceLocator = instanceLocator;
|
_instanceLocator = instanceLocator;
|
||||||
_instanceRouter = instanceRouter;
|
_instanceRouter = instanceRouter;
|
||||||
_deadlineToken = deadlineToken;
|
_deadlineToken = deadlineToken;
|
||||||
|
_parentExecutionId = parentExecutionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -45,14 +48,27 @@ public class RouteHelper
|
|||||||
/// requires.
|
/// requires.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public RouteHelper WithDeadline(CancellationToken deadlineToken) =>
|
public RouteHelper WithDeadline(CancellationToken deadlineToken) =>
|
||||||
new(_instanceLocator, _instanceRouter, deadlineToken);
|
new(_instanceLocator, _instanceRouter, deadlineToken, _parentExecutionId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 (ParentExecutionId): returns a <see cref="RouteHelper"/> whose
|
||||||
|
/// routed <see cref="RouteTarget.Call"/> requests carry
|
||||||
|
/// <paramref name="parentExecutionId"/> as <see cref="RouteToCallRequest.ParentExecutionId"/>.
|
||||||
|
/// For an inbound API request this is the inbound request's own per-request
|
||||||
|
/// execution id, so the routed site script records the inbound request as its
|
||||||
|
/// parent. <see cref="InboundScriptExecutor"/> calls this when it builds the
|
||||||
|
/// script context.
|
||||||
|
/// </summary>
|
||||||
|
public RouteHelper WithParentExecutionId(Guid? parentExecutionId) =>
|
||||||
|
new(_instanceLocator, _instanceRouter, _deadlineToken, parentExecutionId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a route target for the specified instance.
|
/// Creates a route target for the specified instance.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public RouteTarget To(string instanceCode)
|
public RouteTarget To(string instanceCode)
|
||||||
{
|
{
|
||||||
return new RouteTarget(instanceCode, _instanceLocator, _instanceRouter, _deadlineToken);
|
return new RouteTarget(
|
||||||
|
instanceCode, _instanceLocator, _instanceRouter, _deadlineToken, _parentExecutionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,17 +81,20 @@ public class RouteTarget
|
|||||||
private readonly IInstanceLocator _instanceLocator;
|
private readonly IInstanceLocator _instanceLocator;
|
||||||
private readonly IInstanceRouter _instanceRouter;
|
private readonly IInstanceRouter _instanceRouter;
|
||||||
private readonly CancellationToken _deadlineToken;
|
private readonly CancellationToken _deadlineToken;
|
||||||
|
private readonly Guid? _parentExecutionId;
|
||||||
|
|
||||||
internal RouteTarget(
|
internal RouteTarget(
|
||||||
string instanceCode,
|
string instanceCode,
|
||||||
IInstanceLocator instanceLocator,
|
IInstanceLocator instanceLocator,
|
||||||
IInstanceRouter instanceRouter,
|
IInstanceRouter instanceRouter,
|
||||||
CancellationToken deadlineToken)
|
CancellationToken deadlineToken,
|
||||||
|
Guid? parentExecutionId)
|
||||||
{
|
{
|
||||||
_instanceCode = instanceCode;
|
_instanceCode = instanceCode;
|
||||||
_instanceLocator = instanceLocator;
|
_instanceLocator = instanceLocator;
|
||||||
_instanceRouter = instanceRouter;
|
_instanceRouter = instanceRouter;
|
||||||
_deadlineToken = deadlineToken;
|
_deadlineToken = deadlineToken;
|
||||||
|
_parentExecutionId = parentExecutionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -96,8 +115,13 @@ public class RouteTarget
|
|||||||
var siteId = await ResolveSiteAsync(token);
|
var siteId = await ResolveSiteAsync(token);
|
||||||
var correlationId = Guid.NewGuid().ToString();
|
var correlationId = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
// Audit Log #23 (ParentExecutionId): stamp the spawning execution's id
|
||||||
|
// (the inbound API request's ExecutionId) so the routed site script
|
||||||
|
// records this call's parent. CorrelationId above is a separate concern
|
||||||
|
// — the per-operation lifecycle id, freshly minted per routed call.
|
||||||
var request = new RouteToCallRequest(
|
var request = new RouteToCallRequest(
|
||||||
correlationId, _instanceCode, scriptName, ScriptArgs.Normalize(parameters), DateTimeOffset.UtcNow);
|
correlationId, _instanceCode, scriptName, ScriptArgs.Normalize(parameters),
|
||||||
|
DateTimeOffset.UtcNow, _parentExecutionId);
|
||||||
|
|
||||||
var response = await _instanceRouter.RouteToCallAsync(siteId, request, token);
|
var response = await _instanceRouter.RouteToCallAsync(siteId, request, token);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using ScadaLink.Commons.Entities.InboundApi;
|
using ScadaLink.Commons.Entities.InboundApi;
|
||||||
using ScadaLink.Commons.Interfaces.Services;
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
using ScadaLink.Commons.Messages.InboundApi;
|
||||||
|
|
||||||
namespace ScadaLink.InboundAPI.Tests;
|
namespace ScadaLink.InboundAPI.Tests;
|
||||||
|
|
||||||
@@ -427,6 +428,71 @@ public class InboundScriptExecutorTests
|
|||||||
Assert.Contains("whatever", result.ResultJson!);
|
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
|
private sealed class CompileLogCounter
|
||||||
{
|
{
|
||||||
public int CompilationFailures;
|
public int CompilationFailures;
|
||||||
|
|||||||
@@ -395,6 +395,78 @@ public class AuditWriteMiddlewareTests
|
|||||||
Assert.NotEqual(writer.Events[0].ExecutionId, writer.Events[1].ExecutionId);
|
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]
|
[Fact]
|
||||||
public async Task DurationMs_IsRecorded()
|
public async Task DurationMs_IsRecorded()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -233,6 +233,71 @@ public class RouteHelperTests
|
|||||||
Assert.Equal(explicitCts.Token, seen);
|
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]
|
[Fact]
|
||||||
public async Task GetAttributes_WithNoExplicitToken_InheritsMethodDeadlineToken()
|
public async Task GetAttributes_WithNoExplicitToken_InheritsMethodDeadlineToken()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user