feat(inboundapi): mint inbound ExecutionId early, carry it as RouteToCallRequest.ParentExecutionId

This commit is contained in:
Joseph Doherty
2026-05-21 17:22:13 -04:00
parent 50430b9daa
commit d8453bfba2
8 changed files with 326 additions and 21 deletions

View File

@@ -4,12 +4,21 @@ namespace ScadaLink.Commons.Messages.InboundApi;
/// Request routed from Inbound API to a site to invoke a script on an instance.
/// Used by Route.To("instanceCode").Call("scriptName", params).
/// </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(
string CorrelationId,
string InstanceUniqueName,
string ScriptName,
IReadOnlyDictionary<string, object?>? Parameters,
DateTimeOffset Timestamp);
DateTimeOffset Timestamp,
Guid? ParentExecutionId = null);
/// <summary>
/// Response from a Route.To() call.

View File

@@ -92,8 +92,21 @@ public static class EndpointExtensions
? TimeSpan.FromSeconds(method.TimeoutSeconds)
: 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(
method, paramResult.Parameters, routeHelper, timeout, httpContext.RequestAborted);
method, paramResult.Parameters, routeHelper, timeout,
httpContext.RequestAborted, parentExecutionId);
if (!scriptResult.Success)
{

View File

@@ -4,6 +4,7 @@ using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Entities.InboundApi;
using ScadaLink.Commons.Messages.InboundApi;
using ScadaLink.Commons.Types;
namespace ScadaLink.InboundAPI;
@@ -156,12 +157,22 @@ public class InboundScriptExecutor
/// <summary>
/// Executes the script for the given method with the provided context.
/// </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(
ApiMethod method,
IReadOnlyDictionary<string, object?> parameters,
RouteHelper route,
TimeSpan timeout,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
Guid? parentExecutionId = null)
{
// InboundAPI-004: keep the timeout source and the request-abort source
// 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
// routed Route.To(...).Call(...) inherits the method-level timeout
// 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))
{

View File

@@ -59,6 +59,18 @@ public sealed class AuditWriteMiddleware
/// </summary>
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 ICentralAuditWriter _auditWriter;
private readonly ILogger<AuditWriteMiddleware> _logger;
@@ -77,6 +89,17 @@ public sealed class AuditWriteMiddleware
{
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
// downstream handler still parse it. EnableBuffering swaps the request
// stream for a seekable wrapper that the framework rewinds at the end
@@ -145,17 +168,14 @@ public sealed class AuditWriteMiddleware
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiInbound,
Kind = kind,
// Audit Log #23: a fresh per-request execution id so the
// inbound row carries a request identifier (closes the design
// gap that inbound rows should be correlatable).
//
// This id is intentionally request-local: it is NOT bridged to
// RouteHelper's routed-call correlation id or to
// HttpContext.TraceIdentifier. Threading an inbound request's
// execution id through to the routed script execution (so an
// 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(),
// Audit Log #23: the per-request execution id minted ONCE at the
// start of the request (InvokeAsync) and stashed on
// HttpContext.Items. The same id is threaded onto a routed
// RouteToCallRequest.ParentExecutionId by the endpoint handler,
// so an inbound request and the site script it routes to share
// one correlation point. This inbound row stays top-level — its
// own ParentExecutionId is never set (see below).
ExecutionId = ResolveInboundExecutionId(ctx),
// CorrelationId is purely the per-operation-lifecycle id; an
// inbound request is a one-shot from the audit row's
// 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>
/// Reads the API key name the endpoint handler stashed on
/// <see cref="HttpContext.Items"/> after successful auth. Falls back to

View File

@@ -19,22 +19,25 @@ public class RouteHelper
private readonly IInstanceLocator _instanceLocator;
private readonly IInstanceRouter _instanceRouter;
private readonly CancellationToken _deadlineToken;
private readonly Guid? _parentExecutionId;
public RouteHelper(
IInstanceLocator instanceLocator,
IInstanceRouter instanceRouter)
: this(instanceLocator, instanceRouter, CancellationToken.None)
: this(instanceLocator, instanceRouter, CancellationToken.None, parentExecutionId: null)
{
}
private RouteHelper(
IInstanceLocator instanceLocator,
IInstanceRouter instanceRouter,
CancellationToken deadlineToken)
CancellationToken deadlineToken,
Guid? parentExecutionId)
{
_instanceLocator = instanceLocator;
_instanceRouter = instanceRouter;
_deadlineToken = deadlineToken;
_parentExecutionId = parentExecutionId;
}
/// <summary>
@@ -45,14 +48,27 @@ public class RouteHelper
/// requires.
/// </summary>
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>
/// Creates a route target for the specified instance.
/// </summary>
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 IInstanceRouter _instanceRouter;
private readonly CancellationToken _deadlineToken;
private readonly Guid? _parentExecutionId;
internal RouteTarget(
string instanceCode,
IInstanceLocator instanceLocator,
IInstanceRouter instanceRouter,
CancellationToken deadlineToken)
CancellationToken deadlineToken,
Guid? parentExecutionId)
{
_instanceCode = instanceCode;
_instanceLocator = instanceLocator;
_instanceRouter = instanceRouter;
_deadlineToken = deadlineToken;
_parentExecutionId = parentExecutionId;
}
/// <summary>
@@ -96,8 +115,13 @@ public class RouteTarget
var siteId = await ResolveSiteAsync(token);
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(
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);