feat(inbound): routed Route.To().WaitForAttribute — contract + central path (spec §6)

This commit is contained in:
Joseph Doherty
2026-06-17 09:02:21 -04:00
parent cd15426b21
commit 0f6da8a106
7 changed files with 233 additions and 0 deletions
@@ -83,3 +83,46 @@ public record RouteToSetAttributesResponse(
bool Success,
string? ErrorMessage,
DateTimeOffset Timestamp);
/// <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
/// times out.
/// </summary>
/// <param name="ParentExecutionId">
/// Audit Log #23 (ParentExecutionId): mirrors <see cref="RouteToCallRequest.ParentExecutionId"/>.
/// For an inbound-API-routed wait this is the inbound request's per-request execution id;
/// future site-side audit emission for routed waits can stamp it as <c>ParentExecutionId</c>
/// 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>
public record RouteToWaitForAttributeRequest(
string CorrelationId,
string InstanceUniqueName,
string AttributeName,
string? TargetValueEncoded,
TimeSpan Timeout,
DateTimeOffset Timestamp,
Guid? ParentExecutionId = null);
/// <summary>
/// Response from a remote attribute wait. <see cref="Success"/>/<see cref="ErrorMessage"/>
/// convey the routing-level outcome (e.g. instance-not-found); <see cref="Matched"/>,
/// <see cref="TimedOut"/>, <see cref="Value"/>, and <see cref="Quality"/> convey the wait
/// outcome itself. When <see cref="Success"/> is <c>true</c>, exactly one of
/// <see cref="Matched"/>/<see cref="TimedOut"/> holds: <see cref="Matched"/> means the
/// attribute reached the target value (with <see cref="Value"/>/<see cref="Quality"/>
/// captured at the match), <see cref="TimedOut"/> means the deadline elapsed first.
/// </summary>
public record RouteToWaitForAttributeResponse(
string CorrelationId,
bool Matched,
object? Value,
string? Quality,
bool TimedOut,
bool Success,
string? ErrorMessage,
DateTimeOffset Timestamp);
@@ -445,6 +445,25 @@ public class CommunicationService
envelope, _options.IntegrationTimeout, cancellationToken);
}
/// <summary>
/// Routes an inbound API wait-for-attribute request to a site (spec §6).
/// </summary>
/// <param name="siteId">The target site identifier.</param>
/// <param name="request">The wait-for-attribute route request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The wait-for-attribute route response.</returns>
public async Task<RouteToWaitForAttributeResponse> RouteToWaitForAttributeAsync(
string siteId, RouteToWaitForAttributeRequest request, CancellationToken cancellationToken = default)
{
var envelope = new SiteEnvelope(siteId, request);
// A wait legitimately blocks up to request.Timeout on the site, so the cluster
// Ask must be bounded by the WAIT deadline (plus integration-timeout slack for
// the round trip), not the generic IntegrationTimeout used by the other routes.
var askTimeout = request.Timeout + _options.IntegrationTimeout;
return await GetActor().Ask<RouteToWaitForAttributeResponse>(
envelope, askTimeout, cancellationToken);
}
// ── Notification Outbox (central-local actor — Asked directly, no SiteEnvelope) ──
/// <summary>
@@ -35,4 +35,9 @@ public sealed class CommunicationServiceInstanceRouter : IInstanceRouter
public Task<RouteToSetAttributesResponse> RouteToSetAttributesAsync(
string siteId, RouteToSetAttributesRequest request, CancellationToken cancellationToken) =>
_communicationService.RouteToSetAttributesAsync(siteId, request, cancellationToken);
/// <inheritdoc />
public Task<RouteToWaitForAttributeResponse> RouteToWaitForAttributeAsync(
string siteId, RouteToWaitForAttributeRequest request, CancellationToken cancellationToken) =>
_communicationService.RouteToWaitForAttributeAsync(siteId, request, cancellationToken);
}
@@ -34,4 +34,12 @@ public interface IInstanceRouter
/// <returns>A task that resolves to the set-attributes response from the target site.</returns>
Task<RouteToSetAttributesResponse> RouteToSetAttributesAsync(
string siteId, RouteToSetAttributesRequest request, CancellationToken cancellationToken);
/// <summary>Routes a wait-for-attribute request to the specified site (spec §6).</summary>
/// <param name="siteId">Target site identifier.</param>
/// <param name="request">The wait-for-attribute request to route (value-equality only).</param>
/// <param name="cancellationToken">Cancellation token for the routed call.</param>
/// <returns>A task that resolves to the wait-for-attribute response from the target site.</returns>
Task<RouteToWaitForAttributeResponse> RouteToWaitForAttributeAsync(
string siteId, RouteToWaitForAttributeRequest request, CancellationToken cancellationToken);
}
@@ -205,6 +205,47 @@ public class RouteTarget
return response.Values;
}
/// <summary>
/// Blocks until a remote instance attribute reaches <paramref name="targetValue"/>
/// or <paramref name="timeout"/> elapses (spec §6). Value-equality ONLY across the
/// 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.
/// </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>
/// <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,
object? targetValue,
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
var token = Effective(cancellationToken);
var siteId = await ResolveSiteAsync(token);
// Audit Log #23 (ParentExecutionId): mirrors the Call path — stamp the
// spawning inbound request's ExecutionId so future site-side audit
// emission for routed waits can record this wait's parent. CorrelationId
// is the per-operation lifecycle id, freshly minted per routed wait.
var request = new RouteToWaitForAttributeRequest(
Guid.NewGuid().ToString(), _instanceCode, attributeName,
AttributeValueCodec.Encode(targetValue), timeout, DateTimeOffset.UtcNow,
_parentExecutionId);
var response = await _instanceRouter.RouteToWaitForAttributeAsync(siteId, request, token);
if (!response.Success)
{
throw new InvalidOperationException(
response.ErrorMessage ?? "Remote attribute wait failed");
}
return response.Matched;
}
/// <summary>
/// Sets a single attribute value on the remote instance.
/// </summary>