feat(testrun): route WaitForAttribute in the Test-Run sandbox (deferred #14)
Closes deferred-work register item #14 with full fidelity. The Central UI Test-Run sandbox previously threw ScriptSandboxException for Attributes.WaitAsync/WaitForAsync; now the value-equality forms route to the bound deployed instance over the same cross-site path inbound Route.To().WaitForAttribute() uses. - Commons: additive trailing RouteToWaitForAttributeRequest.RequireGoodQuality (default false; message-evolution safe) so quality-gated waits route too. - SiteRuntime: DeploymentManagerActor's routed-wait handler now threads request.RequireGoodQuality into the InstanceActor WaitForAttributeRequest (previously hard-coded to default). - CentralUI sandbox: ISandboxInstanceGateway.WaitForAttributeAsync + SandboxInstanceGateway impl (via CommunicationService.RouteToWaitForAttributeAsync); SandboxAttributeAccessor value-equality WaitAsync/WaitForAsync route through it (codec-encoded target, scope-resolved name). Predicate overloads stay unsupported (an in-process lambda can't be routed) and throw a clearer, dedicated message. - Tests: sandbox accessor routing + predicate/unbound rejection (CentralUI); site-handler RequireGoodQuality threading (SiteRuntime). Register + the historical waitfor-deferred plan doc updated. Full slnx build 0/0; CentralUI 59, SiteRuntime routed-wait 4, InboundAPI wait 32 green. Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
|
||||
@@ -74,4 +75,22 @@ public sealed class SandboxInstanceGateway : ISandboxInstanceGateway
|
||||
$"CallScript(\"{canonicalScriptName}\") on bound instance '{_instanceUniqueName}' failed: {response.ErrorMessage}");
|
||||
return response.ReturnValue;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<WaitResult> WaitForAttributeAsync(
|
||||
string canonicalName, string? targetValueEncoded, TimeSpan timeout, bool requireGoodQuality, CancellationToken ct)
|
||||
{
|
||||
// Reuse the inbound-API cross-site wait route (RouteToWaitForAttributeAsync) — the
|
||||
// same real event-driven waiter Route.To().WaitForAttribute() uses. ParentExecutionId
|
||||
// is null for the sandbox path (no inbound execution to link).
|
||||
var request = new RouteToWaitForAttributeRequest(
|
||||
Guid.NewGuid().ToString(), _instanceUniqueName, canonicalName,
|
||||
targetValueEncoded, timeout, DateTimeOffset.UtcNow,
|
||||
ParentExecutionId: null, RequireGoodQuality: requireGoodQuality);
|
||||
var response = await _comms.RouteToWaitForAttributeAsync(_siteId, request, _runToken);
|
||||
if (!response.Success)
|
||||
throw new ScriptSandboxException(
|
||||
$"WaitForAttribute(\"{canonicalName}\") on bound instance '{_instanceUniqueName}' failed: {response.ErrorMessage}");
|
||||
return new WaitResult(response.Matched, response.Value, response.Quality, response.TimedOut);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,21 @@ public interface ISandboxInstanceGateway
|
||||
/// <returns>The script result, or null if none.</returns>
|
||||
Task<object?> CallScriptAsync(
|
||||
string canonicalScriptName, IReadOnlyDictionary<string, object?>? parameters, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Blocks until the named attribute reaches a target value (value-equality,
|
||||
/// codec-encoded), optionally quality-gated, or the timeout elapses — routed to the
|
||||
/// bound deployed instance cross-site. Value-equality only: a script-side predicate
|
||||
/// cannot be routed, so the sandbox predicate overloads stay unsupported.
|
||||
/// </summary>
|
||||
/// <param name="canonicalName">The (already scope-resolved) canonical attribute name.</param>
|
||||
/// <param name="targetValueEncoded">The <c>AttributeValueCodec</c>-encoded target value, or null for "any change".</param>
|
||||
/// <param name="timeout">Maximum time to wait.</param>
|
||||
/// <param name="requireGoodQuality">When true, only a Good-quality value satisfies the wait.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The wait outcome (matched flag, matched value, quality, timed-out flag).</returns>
|
||||
Task<WaitResult> WaitForAttributeAsync(
|
||||
string canonicalName, string? targetValueEncoded, TimeSpan timeout, bool requireGoodQuality, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -212,6 +227,26 @@ public class SandboxInstanceContext
|
||||
"bind one in Test Run to call sibling scripts.");
|
||||
return _gateway.CallScriptAsync(scriptName, ScriptArgs.Normalize(parameters), CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes a value-equality attribute wait to the bound deployed instance cross-site.
|
||||
/// Needs a bound instance (the site owns the event-driven waiter); throws when unbound.
|
||||
/// </summary>
|
||||
/// <param name="canonicalName">The (already scope-resolved) canonical attribute name.</param>
|
||||
/// <param name="targetValueEncoded">The codec-encoded target value, or null for "any change".</param>
|
||||
/// <param name="timeout">Maximum time to wait.</param>
|
||||
/// <param name="requireGoodQuality">When true, only a Good-quality value satisfies the wait.</param>
|
||||
/// <returns>The wait outcome.</returns>
|
||||
public Task<WaitResult> WaitForAttribute(
|
||||
string canonicalName, string? targetValueEncoded, TimeSpan timeout, bool requireGoodQuality)
|
||||
{
|
||||
if (_gateway == null)
|
||||
throw new ScriptSandboxException(
|
||||
$"WaitForAttribute(\"{canonicalName}\") needs a deployed instance — " +
|
||||
"bind one in Test Run to exercise the event-driven attribute waiter.");
|
||||
return _gateway.WaitForAttributeAsync(
|
||||
canonicalName, targetValueEncoded, timeout, requireGoodQuality, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -342,20 +377,23 @@ public class SandboxAttributeAccessor
|
||||
=> throw NotInSandbox(nameof(WriteBatchAndWaitAsync));
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox stand-in for <c>AttributeAccessor.WaitAsync</c> (value-equality form);
|
||||
/// see <see cref="WriteBatchAndWaitAsync"/>.
|
||||
/// Value-equality attribute wait — routed to the bound deployed instance cross-site
|
||||
/// (the same real event-driven waiter a site uses). Needs a bound instance; throws
|
||||
/// <see cref="ScriptSandboxException"/> when the Test Run has no instance bound.
|
||||
/// </summary>
|
||||
/// <param name="key">The attribute key to observe.</param>
|
||||
/// <param name="targetValue">The value to wait for.</param>
|
||||
/// <param name="targetValue">The value to wait for (codec-normalized), or null for "any change".</param>
|
||||
/// <param name="timeout">The maximum time to wait.</param>
|
||||
/// <param name="requireGoodQuality">Whether the attribute must also have good quality to satisfy the wait.</param>
|
||||
/// <returns>Never returns; always throws <see cref="ScriptSandboxException"/> in the sandbox.</returns>
|
||||
public Task<bool> WaitAsync(string key, object? targetValue, TimeSpan timeout, bool requireGoodQuality = false)
|
||||
=> throw NotInSandbox(nameof(WaitAsync));
|
||||
/// <returns>True when the attribute reached the target within the timeout; false on timeout.</returns>
|
||||
public async Task<bool> WaitAsync(string key, object? targetValue, TimeSpan timeout, bool requireGoodQuality = false)
|
||||
=> (await _ctx.WaitForAttribute(
|
||||
Resolve(key), AttributeValueCodec.Encode(targetValue), timeout, requireGoodQuality)).Matched;
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox stand-in for <c>AttributeAccessor.WaitAsync</c> (predicate form);
|
||||
/// see <see cref="WriteBatchAndWaitAsync"/>.
|
||||
/// Predicate-form attribute wait. Unsupported in Test Run because a script-side
|
||||
/// predicate (an in-process lambda) cannot be evaluated on the remote site — only the
|
||||
/// value-equality overloads route. Throws a clearly-labelled <see cref="ScriptSandboxException"/>.
|
||||
/// </summary>
|
||||
/// <param name="key">The attribute key to observe.</param>
|
||||
/// <param name="predicate">The predicate the attribute value must satisfy.</param>
|
||||
@@ -363,23 +401,25 @@ public class SandboxAttributeAccessor
|
||||
/// <param name="requireGoodQuality">Whether the attribute must also have good quality to satisfy the wait.</param>
|
||||
/// <returns>Never returns; always throws <see cref="ScriptSandboxException"/> in the sandbox.</returns>
|
||||
public Task<bool> WaitAsync(string key, Func<object?, bool> predicate, TimeSpan timeout, bool requireGoodQuality = false)
|
||||
=> throw NotInSandbox(nameof(WaitAsync));
|
||||
=> throw PredicateWaitNotRoutable(nameof(WaitAsync));
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox stand-in for <c>AttributeAccessor.WaitForAsync</c> (value-equality form);
|
||||
/// see <see cref="WriteBatchAndWaitAsync"/>.
|
||||
/// Value-equality attribute wait returning the full <see cref="WaitResult"/> — routed
|
||||
/// to the bound deployed instance cross-site. Needs a bound instance; throws
|
||||
/// <see cref="ScriptSandboxException"/> when the Test Run has no instance bound.
|
||||
/// </summary>
|
||||
/// <param name="key">The attribute key to observe.</param>
|
||||
/// <param name="targetValue">The value to wait for.</param>
|
||||
/// <param name="targetValue">The value to wait for (codec-normalized), or null for "any change".</param>
|
||||
/// <param name="timeout">The maximum time to wait.</param>
|
||||
/// <param name="requireGoodQuality">Whether the attribute must also have good quality to satisfy the wait.</param>
|
||||
/// <returns>Never returns; always throws <see cref="ScriptSandboxException"/> in the sandbox.</returns>
|
||||
/// <returns>The wait outcome (matched flag, matched value, quality, timed-out flag).</returns>
|
||||
public Task<WaitResult> WaitForAsync(string key, object? targetValue, TimeSpan timeout, bool requireGoodQuality = false)
|
||||
=> throw NotInSandbox(nameof(WaitForAsync));
|
||||
=> _ctx.WaitForAttribute(Resolve(key), AttributeValueCodec.Encode(targetValue), timeout, requireGoodQuality);
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox stand-in for <c>AttributeAccessor.WaitForAsync</c> (predicate form);
|
||||
/// see <see cref="WriteBatchAndWaitAsync"/>.
|
||||
/// Predicate-form attribute wait returning the full <see cref="WaitResult"/>.
|
||||
/// Unsupported in Test Run for the same reason as the predicate <see cref="WaitAsync(string, Func{object?, bool}, TimeSpan, bool)"/>
|
||||
/// overload — an in-process predicate cannot be routed to the remote site.
|
||||
/// </summary>
|
||||
/// <param name="key">The attribute key to observe.</param>
|
||||
/// <param name="predicate">The predicate the attribute value must satisfy.</param>
|
||||
@@ -387,12 +427,18 @@ public class SandboxAttributeAccessor
|
||||
/// <param name="requireGoodQuality">Whether the attribute must also have good quality to satisfy the wait.</param>
|
||||
/// <returns>Never returns; always throws <see cref="ScriptSandboxException"/> in the sandbox.</returns>
|
||||
public Task<WaitResult> WaitForAsync(string key, Func<object?, bool> predicate, TimeSpan timeout, bool requireGoodQuality = false)
|
||||
=> throw NotInSandbox(nameof(WaitForAsync));
|
||||
=> throw PredicateWaitNotRoutable(nameof(WaitForAsync));
|
||||
|
||||
private static ScriptSandboxException NotInSandbox(string member) =>
|
||||
new($"{member}(...) drives live device tags and the site's event-driven " +
|
||||
"attribute waiter, which aren't available in the central Test Run sandbox — " +
|
||||
"deploy to a site to exercise batch-write/wait handshakes.");
|
||||
|
||||
private static ScriptSandboxException PredicateWaitNotRoutable(string member) =>
|
||||
new($"{member}(...) with a predicate can't run in Test Run — a script-side " +
|
||||
"predicate is an in-process lambda that cannot be evaluated on the remote " +
|
||||
"site. Use the value-equality overload (which routes to the bound instance), " +
|
||||
"or deploy to a site to exercise predicate waits.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -87,9 +87,10 @@ public record RouteToSetAttributesResponse(
|
||||
/// <summary>
|
||||
/// Request to block until a remote instance attribute reaches a target value
|
||||
/// (spec §6 — <c>Route.To("inst").WaitForAttribute(name, targetValue, timeout)</c>).
|
||||
/// Value-equality ONLY across the wire: <see cref="TargetValueEncoded"/> carries the
|
||||
/// canonical <c>AttributeValueCodec</c>-encoded target; there is no predicate and no
|
||||
/// quality flag in the comparison. The site evaluates equality and either matches or
|
||||
/// Value-equality across the wire: <see cref="TargetValueEncoded"/> carries the
|
||||
/// canonical <c>AttributeValueCodec</c>-encoded target; there is no predicate (a
|
||||
/// cross-process lambda cannot be routed). <see cref="RequireGoodQuality"/> optionally
|
||||
/// gates the match on Good quality. The site evaluates the match and either matches or
|
||||
/// times out.
|
||||
/// </summary>
|
||||
/// <param name="ParentExecutionId">
|
||||
@@ -99,6 +100,12 @@ public record RouteToSetAttributesResponse(
|
||||
/// so the inbound→site execution-tree link survives the wait path. Additive trailing
|
||||
/// member — null for the Central UI sandbox path or for callers built before the field existed.
|
||||
/// </param>
|
||||
/// <param name="RequireGoodQuality">
|
||||
/// Quality-gated ("Good"-only) mode (spec §4.2): when true, a value reaching the target at
|
||||
/// Bad/Uncertain quality is NOT a match — the site holds the wait until the value satisfies
|
||||
/// the target at Good quality (or times out). Additive trailing member; defaults to
|
||||
/// <see langword="false"/> (quality-agnostic) so older callers and the wire default are unchanged.
|
||||
/// </param>
|
||||
public record RouteToWaitForAttributeRequest(
|
||||
string CorrelationId,
|
||||
string InstanceUniqueName,
|
||||
@@ -106,7 +113,8 @@ public record RouteToWaitForAttributeRequest(
|
||||
string? TargetValueEncoded,
|
||||
TimeSpan Timeout,
|
||||
DateTimeOffset Timestamp,
|
||||
Guid? ParentExecutionId = null);
|
||||
Guid? ParentExecutionId = null,
|
||||
bool RequireGoodQuality = false);
|
||||
|
||||
/// <summary>
|
||||
/// Response from a remote attribute wait. <see cref="Success"/>/<see cref="ErrorMessage"/>
|
||||
|
||||
@@ -1756,10 +1756,11 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
/// Spec §6 (WD-2b): unpacks a routed <see cref="RouteToWaitForAttributeRequest"/>
|
||||
/// (inbound-API <c>Route.To().WaitForAttribute()</c>) into the deployed
|
||||
/// Instance Actor's site-local <see cref="WaitForAttributeRequest"/> and relays
|
||||
/// the result back. Value-equality only across the wire — the predicate is null
|
||||
/// and <c>RequireGoodQuality</c> is left at its default. The Ask is bounded by the
|
||||
/// wait timeout plus slack (NOT a fixed 30s), since the wait legitimately blocks
|
||||
/// for up to <see cref="RouteToWaitForAttributeRequest.Timeout"/>.
|
||||
/// the result back. Value-equality across the wire — the predicate is null (a
|
||||
/// cross-process lambda cannot be routed), but <c>RequireGoodQuality</c> is honored
|
||||
/// from <see cref="RouteToWaitForAttributeRequest.RequireGoodQuality"/>. The Ask is
|
||||
/// bounded by the wait timeout plus slack (NOT a fixed 30s), since the wait
|
||||
/// legitimately blocks for up to <see cref="RouteToWaitForAttributeRequest.Timeout"/>.
|
||||
/// </summary>
|
||||
private void RouteInboundApiWaitForAttribute(RouteToWaitForAttributeRequest request)
|
||||
{
|
||||
@@ -1773,10 +1774,12 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
|
||||
var sender = Sender;
|
||||
// Routed waits are value-equality only (predicate null); RequireGoodQuality left at default.
|
||||
// Routed waits are value-equality (predicate null — a cross-process lambda
|
||||
// cannot be routed); RequireGoodQuality is honored from the request.
|
||||
var inner = new WaitForAttributeRequest(
|
||||
request.CorrelationId, request.InstanceUniqueName, request.AttributeName,
|
||||
request.TargetValueEncoded, null, request.Timeout, DateTimeOffset.UtcNow);
|
||||
request.TargetValueEncoded, null, request.Timeout, DateTimeOffset.UtcNow,
|
||||
request.RequireGoodQuality);
|
||||
|
||||
// Ask bounded by the WAIT timeout + slack — NOT a fixed 30s (the wait legitimately blocks up to request.Timeout).
|
||||
instanceActor.Ask<WaitForAttributeResponse>(inner, request.Timeout + TimeSpan.FromSeconds(5))
|
||||
|
||||
Reference in New Issue
Block a user