fix(inbound): authorize+secure Database helper, async/deadline-bound DB, wait-timeout-bound WaitForAttribute
Resolves InboundAPI-026/027/028/029 (+ newly-surfaced -030). - 026: authorize the scoped Database helper in the design doc; SQL-injection protection is parameter binding (values never concatenated); allow writes via ExecuteAsync; drop the false 'read-only' claim. Named connections only. - 027: async ADO.NET end-to-end (no .GetAwaiter().GetResult()); honour the method deadline token on ExecuteScalarAsync/ExecuteReaderAsync/ExecuteNonQueryAsync + a CommandTimeout backstop derived from the method timeout. - 028: negative-path tests (null-gateway, deadline cancellation, parameterization) + e2e Database + WaitForAttribute cases through the real endpoint. - 029: WaitForAttribute is bounded by its WAIT timeout (per-wait CTS + client-abort + explicit token), NOT the method deadline (spec §6) — a long wait may outlive the method timeout; WithRequestAborted threads the raw client-abort token separately. - 030: Central UI compile-surface mirrors (InboundScriptHost / SandboxInboundScriptHost) gained the Database member (drifted since the runtime helper was added) so the authorized async API type-checks at the design-time gate.
This commit is contained in:
@@ -20,11 +20,52 @@ public class InboundScriptHost
|
||||
/// </summary>
|
||||
public RouteHelper Route { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Scoped, parameterized database access. Editor mirror of
|
||||
/// ZB.MOM.WW.ScadaBridge.InboundAPI.InboundDatabaseHelper (InboundAPI-026/030) — its
|
||||
/// signatures must match the runtime helper so a script using <c>Database.*</c>
|
||||
/// type-checks during analysis.
|
||||
/// </summary>
|
||||
public DatabaseAccessor Database { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// The cancellation token for the operation.
|
||||
/// </summary>
|
||||
public System.Threading.CancellationToken CancellationToken { get; }
|
||||
|
||||
/// <summary>Editor mirror of ZB.MOM.WW.ScadaBridge.InboundAPI.InboundDatabaseHelper.</summary>
|
||||
public class DatabaseAccessor
|
||||
{
|
||||
/// <summary>First column of the first row as <typeparamref name="T"/> (default if no rows).</summary>
|
||||
/// <typeparam name="T">The scalar result type.</typeparam>
|
||||
/// <param name="connectionName">A configured database connection name.</param>
|
||||
/// <param name="sql">The SQL statement; values supplied via <paramref name="parameters"/>.</param>
|
||||
/// <param name="parameters">Optional anonymous object of bound parameters.</param>
|
||||
/// <returns>A task that resolves to the scalar, or default.</returns>
|
||||
public System.Threading.Tasks.Task<T?> QuerySingleAsync<T>(
|
||||
string connectionName, string sql, object? parameters = null) =>
|
||||
System.Threading.Tasks.Task.FromResult<T?>(default);
|
||||
|
||||
/// <summary>All rows as case-insensitive column→value dictionaries.</summary>
|
||||
/// <param name="connectionName">A configured database connection name.</param>
|
||||
/// <param name="sql">The SQL statement; values supplied via <paramref name="parameters"/>.</param>
|
||||
/// <param name="parameters">Optional anonymous object of bound parameters.</param>
|
||||
/// <returns>A task that resolves to the result rows.</returns>
|
||||
public System.Threading.Tasks.Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string connectionName, string sql, object? parameters = null) =>
|
||||
System.Threading.Tasks.Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>(
|
||||
new List<IReadOnlyDictionary<string, object?>>());
|
||||
|
||||
/// <summary>Runs a write statement; returns rows affected.</summary>
|
||||
/// <param name="connectionName">A configured database connection name.</param>
|
||||
/// <param name="sql">The SQL statement; values supplied via <paramref name="parameters"/>.</param>
|
||||
/// <param name="parameters">Optional anonymous object of bound parameters.</param>
|
||||
/// <returns>A task that resolves to the number of rows affected.</returns>
|
||||
public System.Threading.Tasks.Task<int> ExecuteAsync(
|
||||
string connectionName, string sql, object? parameters = null) =>
|
||||
System.Threading.Tasks.Task.FromResult(0);
|
||||
}
|
||||
|
||||
/// <summary>Editor mirror of ZB.MOM.WW.ScadaBridge.InboundAPI.RouteHelper.</summary>
|
||||
public class RouteHelper
|
||||
{
|
||||
|
||||
@@ -23,6 +23,48 @@ public class SandboxInboundScriptHost
|
||||
/// <summary>Gets the route accessor; every call throws <see cref="ScriptSandboxException"/> in a test run.</summary>
|
||||
public RouteAccessor Route { get; } = new();
|
||||
|
||||
/// <summary>Gets the database accessor; every call throws <see cref="ScriptSandboxException"/> in a test run.</summary>
|
||||
public DatabaseAccessor Database { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Mirror of ZB.MOM.WW.ScadaBridge.InboundAPI.InboundDatabaseHelper — signatures match
|
||||
/// the runtime helper so the same user code compiles, but every call throws because a
|
||||
/// central Test Run has no configured database connection to reach (mirrors how
|
||||
/// <see cref="RouteAccessor"/> throws on cross-site routing).
|
||||
/// </summary>
|
||||
public class DatabaseAccessor
|
||||
{
|
||||
/// <summary>Always throws <see cref="ScriptSandboxException"/>; database access is unavailable in a Test Run.</summary>
|
||||
/// <typeparam name="T">The scalar result type.</typeparam>
|
||||
/// <param name="connectionName">Connection name (included in the exception message).</param>
|
||||
/// <param name="sql">Unused SQL.</param>
|
||||
/// <param name="parameters">Unused parameters.</param>
|
||||
/// <returns>Never returns; always throws <see cref="ScriptSandboxException"/>.</returns>
|
||||
public Task<T?> QuerySingleAsync<T>(string connectionName, string sql, object? parameters = null) =>
|
||||
throw Unavailable($"Database.QuerySingleAsync(\"{connectionName}\")");
|
||||
|
||||
/// <summary>Always throws <see cref="ScriptSandboxException"/>; database access is unavailable in a Test Run.</summary>
|
||||
/// <param name="connectionName">Connection name (included in the exception message).</param>
|
||||
/// <param name="sql">Unused SQL.</param>
|
||||
/// <param name="parameters">Unused parameters.</param>
|
||||
/// <returns>Never returns; always throws <see cref="ScriptSandboxException"/>.</returns>
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string connectionName, string sql, object? parameters = null) =>
|
||||
throw Unavailable($"Database.QueryAsync(\"{connectionName}\")");
|
||||
|
||||
/// <summary>Always throws <see cref="ScriptSandboxException"/>; database access is unavailable in a Test Run.</summary>
|
||||
/// <param name="connectionName">Connection name (included in the exception message).</param>
|
||||
/// <param name="sql">Unused SQL.</param>
|
||||
/// <param name="parameters">Unused parameters.</param>
|
||||
/// <returns>Never returns; always throws <see cref="ScriptSandboxException"/>.</returns>
|
||||
public Task<int> ExecuteAsync(string connectionName, string sql, object? parameters = null) =>
|
||||
throw Unavailable($"Database.ExecuteAsync(\"{connectionName}\")");
|
||||
|
||||
private static ScriptSandboxException Unavailable(string operation) =>
|
||||
new($"{operation} is not available in Test Run — database access needs the " +
|
||||
"central configuration / machine-data databases.");
|
||||
}
|
||||
|
||||
/// <summary>Mirror of ZB.MOM.WW.ScadaBridge.InboundAPI.RouteHelper.</summary>
|
||||
public class RouteAccessor
|
||||
{
|
||||
|
||||
@@ -5,45 +5,90 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only database access exposed to inbound API scripts. All ADO.NET stays
|
||||
/// internal here — scripts call QuerySingle/Query by name and never reference
|
||||
/// System.Data. Named connections only; parameters are bound (anonymous-object
|
||||
/// properties become @-prefixed SQL parameters), never string-concatenated.
|
||||
/// Scoped, parameterized database access exposed to inbound API scripts as
|
||||
/// <c>InboundScriptContext.Database</c>. This is the dedicated, curated data-access
|
||||
/// helper authorized by the design doc (Component-InboundAPI.md, "Database access") —
|
||||
/// not a raw connection handed to the script. All ADO.NET stays internal here: scripts
|
||||
/// call <see cref="QueryAsync"/> / <see cref="QuerySingleAsync{T}"/> / <see cref="ExecuteAsync"/>
|
||||
/// by name and never reference <c>System.Data</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>SQL-injection protection (InboundAPI-026).</b> Statement text is authored by the
|
||||
/// (design-time) method script, but every <em>value</em> is bound as a named SQL
|
||||
/// parameter (anonymous-object properties become <c>@</c>-prefixed parameters via
|
||||
/// <see cref="AddParameters"/>) and is NEVER string-concatenated into the command text.
|
||||
/// Request-derived values therefore reach the database only through parameter binding,
|
||||
/// closing the injection vector. Connection access is restricted to the named
|
||||
/// connections configured on the central <see cref="IDatabaseGateway"/> — a script
|
||||
/// cannot supply an arbitrary connection string.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Reads and writes are both permitted</b> (InboundAPI-026 design decision): the move-in
|
||||
/// integration needs to record results, not just read them. Use <see cref="QueryAsync"/>
|
||||
/// / <see cref="QuerySingleAsync{T}"/> for reads and <see cref="ExecuteAsync"/> for writes.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Async + deadline-bound (InboundAPI-027).</b> Every call uses the async ADO.NET path
|
||||
/// end-to-end (no <c>.GetAwaiter().GetResult()</c> blocking a pool thread) and honours the
|
||||
/// executing method's deadline token on the command itself, with a <see cref="DbCommand.CommandTimeout"/>
|
||||
/// backstop derived from the method timeout — so a slow query is bounded by the method
|
||||
/// timeout instead of running unbounded.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class InboundDatabaseHelper
|
||||
{
|
||||
private readonly IDatabaseGateway? _gateway;
|
||||
private readonly CancellationToken _ct;
|
||||
private readonly int _commandTimeoutSeconds;
|
||||
|
||||
public InboundDatabaseHelper(IDatabaseGateway? gateway, CancellationToken ct)
|
||||
{ _gateway = gateway; _ct = ct; }
|
||||
/// <summary>
|
||||
/// Initializes the helper.
|
||||
/// </summary>
|
||||
/// <param name="gateway">The central database gateway, or null when no gateway is registered (the helper then throws on first use).</param>
|
||||
/// <param name="ct">The executing method's deadline token; forwarded to every async DB call so a slow query is bounded by the method timeout.</param>
|
||||
/// <param name="commandTimeout">The method timeout; used to derive a <see cref="DbCommand.CommandTimeout"/> backstop. <see cref="TimeSpan.Zero"/> (the default) leaves the provider default.</param>
|
||||
public InboundDatabaseHelper(IDatabaseGateway? gateway, CancellationToken ct, TimeSpan commandTimeout = default)
|
||||
{
|
||||
_gateway = gateway;
|
||||
_ct = ct;
|
||||
_commandTimeoutSeconds = commandTimeout > TimeSpan.Zero
|
||||
? (int)Math.Ceiling(commandTimeout.TotalSeconds)
|
||||
: 0;
|
||||
}
|
||||
|
||||
/// <summary>First column of the first row converted to T (default if no rows).</summary>
|
||||
public T? QuerySingle<T>(string connectionName, string sql, object? parameters = null)
|
||||
/// <summary>First column of the first row converted to <typeparamref name="T"/> (default if no rows).</summary>
|
||||
/// <typeparam name="T">The type to convert the scalar result to.</typeparam>
|
||||
/// <param name="connectionName">Name of a connection configured on the central database gateway.</param>
|
||||
/// <param name="sql">The SQL statement. Values MUST be supplied via <paramref name="parameters"/>, not concatenated.</param>
|
||||
/// <param name="parameters">Optional anonymous object whose properties become bound <c>@</c>-prefixed parameters.</param>
|
||||
/// <returns>The converted scalar, or <c>default</c> when there are no rows / a NULL value.</returns>
|
||||
public async Task<T?> QuerySingleAsync<T>(string connectionName, string sql, object? parameters = null)
|
||||
{
|
||||
if (_gateway is null) throw new InvalidOperationException("Database is not available for this inbound method");
|
||||
using var conn = _gateway.GetConnectionAsync(connectionName, _ct).GetAwaiter().GetResult();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
AddParameters(cmd, parameters);
|
||||
var result = cmd.ExecuteScalar();
|
||||
await using var conn = await _gateway.GetConnectionAsync(connectionName, _ct);
|
||||
await using var cmd = CreateCommand(conn, sql, parameters);
|
||||
var result = await cmd.ExecuteScalarAsync(_ct);
|
||||
if (result is null or DBNull) return default;
|
||||
if (result is T t) return t;
|
||||
return (T)Convert.ChangeType(result, typeof(T), CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>All rows as column→value dictionaries (case-insensitive keys).</summary>
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, object?>> Query(
|
||||
/// <param name="connectionName">Name of a connection configured on the central database gateway.</param>
|
||||
/// <param name="sql">The SQL statement. Values MUST be supplied via <paramref name="parameters"/>, not concatenated.</param>
|
||||
/// <param name="parameters">Optional anonymous object whose properties become bound <c>@</c>-prefixed parameters.</param>
|
||||
/// <returns>The result rows; empty when nothing matches.</returns>
|
||||
public async Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string connectionName, string sql, object? parameters = null)
|
||||
{
|
||||
if (_gateway is null) throw new InvalidOperationException("Database is not available for this inbound method");
|
||||
using var conn = _gateway.GetConnectionAsync(connectionName, _ct).GetAwaiter().GetResult();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
AddParameters(cmd, parameters);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
await using var conn = await _gateway.GetConnectionAsync(connectionName, _ct);
|
||||
await using var cmd = CreateCommand(conn, sql, parameters);
|
||||
await using var reader = await cmd.ExecuteReaderAsync(_ct);
|
||||
var rows = new List<IReadOnlyDictionary<string, object?>>();
|
||||
while (reader.Read())
|
||||
while (await reader.ReadAsync(_ct))
|
||||
{
|
||||
var row = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 0; i < reader.FieldCount; i++)
|
||||
@@ -56,6 +101,35 @@ public sealed class InboundDatabaseHelper
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a write statement (INSERT/UPDATE/DELETE/DDL) and returns the number of
|
||||
/// rows affected. Writes are authorized for inbound API scripts (InboundAPI-026);
|
||||
/// values are still bound as parameters, never concatenated.
|
||||
/// </summary>
|
||||
/// <param name="connectionName">Name of a connection configured on the central database gateway.</param>
|
||||
/// <param name="sql">The SQL statement. Values MUST be supplied via <paramref name="parameters"/>, not concatenated.</param>
|
||||
/// <param name="parameters">Optional anonymous object whose properties become bound <c>@</c>-prefixed parameters.</param>
|
||||
/// <returns>The number of rows affected.</returns>
|
||||
public async Task<int> ExecuteAsync(string connectionName, string sql, object? parameters = null)
|
||||
{
|
||||
if (_gateway is null) throw new InvalidOperationException("Database is not available for this inbound method");
|
||||
await using var conn = await _gateway.GetConnectionAsync(connectionName, _ct);
|
||||
await using var cmd = CreateCommand(conn, sql, parameters);
|
||||
return await cmd.ExecuteNonQueryAsync(_ct);
|
||||
}
|
||||
|
||||
private DbCommand CreateCommand(DbConnection conn, string sql, object? parameters)
|
||||
{
|
||||
var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
// InboundAPI-027: a CommandTimeout backstop derived from the method timeout so a
|
||||
// slow query cannot outrun the method deadline even if the provider does not
|
||||
// honour token cancellation mid-statement.
|
||||
if (_commandTimeoutSeconds > 0) cmd.CommandTimeout = _commandTimeoutSeconds;
|
||||
AddParameters(cmd, parameters);
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static void AddParameters(DbCommand cmd, object? parameters)
|
||||
{
|
||||
if (parameters is null) return;
|
||||
|
||||
@@ -237,12 +237,13 @@ public class InboundScriptExecutor
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
cancellationToken, timeoutCts.Token);
|
||||
|
||||
// IpsenMES MoveIn: expose a read-only Database helper to the script.
|
||||
// Resolve the gateway from a fresh DI scope (declared outside the try so it
|
||||
// lives until after the script handler runs and is disposed here) so a scoped
|
||||
// IDatabaseGateway is honoured. GetService (not GetRequiredService) so a method
|
||||
// that never touches Database still runs even when no IDatabaseGateway is
|
||||
// registered; the helper throws on first use if built without a gateway.
|
||||
// IpsenMES MoveIn: expose the scoped, parameterized Database helper to the script
|
||||
// (reads + writes — InboundAPI-026). Resolve the gateway from a fresh DI scope
|
||||
// (declared outside the try so it lives until after the script handler runs and is
|
||||
// disposed here) so a scoped IDatabaseGateway is honoured. GetService (not
|
||||
// GetRequiredService) so a method that never touches Database still runs even when
|
||||
// no IDatabaseGateway is registered; the helper throws on first use if built
|
||||
// without a gateway.
|
||||
//
|
||||
// A service provider that cannot produce a scope (e.g. a bare test double with
|
||||
// no IServiceScopeFactory) is tolerated: the gateway is simply unavailable, so
|
||||
@@ -265,18 +266,28 @@ public class InboundScriptExecutor
|
||||
try
|
||||
{
|
||||
var gateway = scope?.ServiceProvider.GetService<IDatabaseGateway>();
|
||||
var dbHelper = new InboundDatabaseHelper(gateway, cts.Token);
|
||||
// InboundAPI-027: pass the method timeout so the helper derives a
|
||||
// CommandTimeout backstop and forwards cts.Token to every async DB call —
|
||||
// a slow query is then bounded by the method deadline.
|
||||
var dbHelper = new InboundDatabaseHelper(gateway, cts.Token, timeout);
|
||||
|
||||
// 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.
|
||||
//
|
||||
// InboundAPI-029: also pass the raw request-abort token separately so
|
||||
// Route.To(...).WaitForAttribute(...) can be bounded by its WAIT timeout
|
||||
// (not the generic method deadline) while still being cancelled by a
|
||||
// client disconnect — see RouteTarget.WaitForAttribute.
|
||||
//
|
||||
// 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),
|
||||
route.WithDeadline(cts.Token)
|
||||
.WithRequestAborted(cancellationToken)
|
||||
.WithParentExecutionId(parentExecutionId),
|
||||
dbHelper,
|
||||
cts.Token);
|
||||
|
||||
@@ -382,7 +393,8 @@ public class InboundScriptContext
|
||||
public RouteHelper Route { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Read-only database access for the script (named connections; bound parameters).
|
||||
/// Scoped, parameterized database access for the script (named connections; bound
|
||||
/// parameters; reads and writes — see <see cref="InboundDatabaseHelper"/>).
|
||||
/// </summary>
|
||||
public InboundDatabaseHelper Database { get; }
|
||||
|
||||
@@ -396,7 +408,7 @@ public class InboundScriptContext
|
||||
/// </summary>
|
||||
/// <param name="parameters">The input parameters for the script.</param>
|
||||
/// <param name="route">The route helper for cross-site routing.</param>
|
||||
/// <param name="database">The read-only database helper for the script.</param>
|
||||
/// <param name="database">The scoped, parameterized database helper for the script.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
public InboundScriptContext(
|
||||
IReadOnlyDictionary<string, object?> parameters,
|
||||
|
||||
@@ -19,6 +19,7 @@ public class RouteHelper
|
||||
private readonly IInstanceLocator _instanceLocator;
|
||||
private readonly IInstanceRouter _instanceRouter;
|
||||
private readonly CancellationToken _deadlineToken;
|
||||
private readonly CancellationToken _requestAbortedToken;
|
||||
private readonly Guid? _parentExecutionId;
|
||||
|
||||
/// <summary>
|
||||
@@ -29,7 +30,7 @@ public class RouteHelper
|
||||
public RouteHelper(
|
||||
IInstanceLocator instanceLocator,
|
||||
IInstanceRouter instanceRouter)
|
||||
: this(instanceLocator, instanceRouter, CancellationToken.None, parentExecutionId: null)
|
||||
: this(instanceLocator, instanceRouter, CancellationToken.None, CancellationToken.None, parentExecutionId: null)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -37,11 +38,13 @@ public class RouteHelper
|
||||
IInstanceLocator instanceLocator,
|
||||
IInstanceRouter instanceRouter,
|
||||
CancellationToken deadlineToken,
|
||||
CancellationToken requestAbortedToken,
|
||||
Guid? parentExecutionId)
|
||||
{
|
||||
_instanceLocator = instanceLocator;
|
||||
_instanceRouter = instanceRouter;
|
||||
_deadlineToken = deadlineToken;
|
||||
_requestAbortedToken = requestAbortedToken;
|
||||
_parentExecutionId = parentExecutionId;
|
||||
}
|
||||
|
||||
@@ -55,7 +58,20 @@ public class RouteHelper
|
||||
/// <param name="deadlineToken">The executing method's timeout cancellation token to inherit for routed calls.</param>
|
||||
/// <returns>A new <see cref="RouteHelper"/> inheriting the given deadline token.</returns>
|
||||
public RouteHelper WithDeadline(CancellationToken deadlineToken) =>
|
||||
new(_instanceLocator, _instanceRouter, deadlineToken, _parentExecutionId);
|
||||
new(_instanceLocator, _instanceRouter, deadlineToken, _requestAbortedToken, _parentExecutionId);
|
||||
|
||||
/// <summary>
|
||||
/// InboundAPI-029: returns a <see cref="RouteHelper"/> carrying the raw request-abort
|
||||
/// token (a client disconnect) <em>separately</em> from the method deadline. Most
|
||||
/// routed calls remain bounded by the method deadline (which already incorporates the
|
||||
/// abort), but <see cref="RouteTarget.WaitForAttribute"/> uses this token so its wait
|
||||
/// is bounded by the WAIT timeout — not the generic method deadline (spec §6) — while
|
||||
/// still being cancelled by a client disconnect.
|
||||
/// </summary>
|
||||
/// <param name="requestAbortedToken">The raw client-disconnect token, independent of the method timeout.</param>
|
||||
/// <returns>A new <see cref="RouteHelper"/> carrying the given request-abort token.</returns>
|
||||
public RouteHelper WithRequestAborted(CancellationToken requestAbortedToken) =>
|
||||
new(_instanceLocator, _instanceRouter, _deadlineToken, requestAbortedToken, _parentExecutionId);
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ParentExecutionId): returns a <see cref="RouteHelper"/> whose
|
||||
@@ -69,7 +85,7 @@ public class RouteHelper
|
||||
/// <param name="parentExecutionId">The inbound request's execution id to stamp as the spawning parent on routed calls, or null for non-routed runs.</param>
|
||||
/// <returns>A new <see cref="RouteHelper"/> carrying the given parent execution id.</returns>
|
||||
public RouteHelper WithParentExecutionId(Guid? parentExecutionId) =>
|
||||
new(_instanceLocator, _instanceRouter, _deadlineToken, parentExecutionId);
|
||||
new(_instanceLocator, _instanceRouter, _deadlineToken, _requestAbortedToken, parentExecutionId);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a route target for the specified instance.
|
||||
@@ -79,7 +95,7 @@ public class RouteHelper
|
||||
public RouteTarget To(string instanceCode)
|
||||
{
|
||||
return new RouteTarget(
|
||||
instanceCode, _instanceLocator, _instanceRouter, _deadlineToken, _parentExecutionId);
|
||||
instanceCode, _instanceLocator, _instanceRouter, _deadlineToken, _requestAbortedToken, _parentExecutionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,10 +104,18 @@ public class RouteHelper
|
||||
/// </summary>
|
||||
public class RouteTarget
|
||||
{
|
||||
// InboundAPI-029: a small grace past the wait timeout. The SITE enforces the wait
|
||||
// timeout and returns Matched=false when it elapses; the local backstop fires only
|
||||
// if the site fails to respond, so it must sit slightly LATER than the wait timeout
|
||||
// (it must not pre-empt the site's own timed-out response and turn a clean `false`
|
||||
// into a cancellation).
|
||||
private static readonly TimeSpan WaitResponseGrace = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly string _instanceCode;
|
||||
private readonly IInstanceLocator _instanceLocator;
|
||||
private readonly IInstanceRouter _instanceRouter;
|
||||
private readonly CancellationToken _deadlineToken;
|
||||
private readonly CancellationToken _requestAbortedToken;
|
||||
private readonly Guid? _parentExecutionId;
|
||||
|
||||
/// <summary>
|
||||
@@ -101,18 +125,21 @@ public class RouteTarget
|
||||
/// <param name="instanceLocator">Service to resolve the site id for the instance.</param>
|
||||
/// <param name="instanceRouter">Service to route cross-site calls.</param>
|
||||
/// <param name="deadlineToken">Cancellation token representing the method-level deadline.</param>
|
||||
/// <param name="requestAbortedToken">Raw client-disconnect token, independent of the method timeout (InboundAPI-029).</param>
|
||||
/// <param name="parentExecutionId">Optional parent execution id for audit correlation on routed calls.</param>
|
||||
internal RouteTarget(
|
||||
string instanceCode,
|
||||
IInstanceLocator instanceLocator,
|
||||
IInstanceRouter instanceRouter,
|
||||
CancellationToken deadlineToken,
|
||||
CancellationToken requestAbortedToken,
|
||||
Guid? parentExecutionId)
|
||||
{
|
||||
_instanceCode = instanceCode;
|
||||
_instanceLocator = instanceLocator;
|
||||
_instanceRouter = instanceRouter;
|
||||
_deadlineToken = deadlineToken;
|
||||
_requestAbortedToken = requestAbortedToken;
|
||||
_parentExecutionId = parentExecutionId;
|
||||
}
|
||||
|
||||
@@ -211,11 +238,22 @@ public class RouteTarget
|
||||
/// wire: the target is canonically encoded via <see cref="AttributeValueCodec"/> and
|
||||
/// the site evaluates equality — there is no predicate and no quality flag in the
|
||||
/// comparison.
|
||||
///
|
||||
/// <para>
|
||||
/// InboundAPI-029: unlike the other routed calls (which inherit the method deadline
|
||||
/// per InboundAPI-016), the wait is bounded by <paramref name="timeout"/> — the WAIT
|
||||
/// timeout — NOT the generic method deadline. The SITE enforces <paramref name="timeout"/>
|
||||
/// and returns <c>Matched=false</c> when it elapses; the local token is built from a
|
||||
/// per-wait CTS (the wait timeout plus a small grace), an explicit caller token, and
|
||||
/// the client-disconnect token, but deliberately EXCLUDES the method deadline. So a
|
||||
/// wait longer than the method timeout runs to its full wait timeout, as spec §6
|
||||
/// requires, while a client disconnect still cancels it.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="attributeName">Name of the attribute to wait on.</param>
|
||||
/// <param name="targetValue">Target value the attribute must equal for the wait to match.</param>
|
||||
/// <param name="timeout">Maximum time to wait for the attribute to reach the target value.</param>
|
||||
/// <param name="cancellationToken">Optional cancellation token; defaults to the method deadline.</param>
|
||||
/// <param name="timeout">Maximum time to wait for the attribute to reach the target value; this — not the method deadline — bounds the wait.</param>
|
||||
/// <param name="cancellationToken">Optional explicit cancellation token (a tighter caller-supplied bound); the wait is otherwise bounded by <paramref name="timeout"/>.</param>
|
||||
/// <returns>A task that resolves to <c>true</c> if the attribute reached the target value, <c>false</c> if the wait timed out.</returns>
|
||||
public async Task<bool> WaitForAttribute(
|
||||
string attributeName,
|
||||
@@ -223,7 +261,12 @@ public class RouteTarget
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var token = Effective(cancellationToken);
|
||||
// InboundAPI-029: bound the wait by the WAIT timeout (+ grace backstop), the
|
||||
// client-disconnect token, and an explicit caller token — NOT the method deadline.
|
||||
using var waitCts = new CancellationTokenSource(timeout + WaitResponseGrace);
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
waitCts.Token, _requestAbortedToken, cancellationToken);
|
||||
var token = linked.Token;
|
||||
var siteId = await ResolveSiteAsync(token);
|
||||
|
||||
// Audit Log #23 (ParentExecutionId): mirrors the Call path — stamp the
|
||||
|
||||
Reference in New Issue
Block a user