feat(siteruntime): event-driven Attributes.WaitAsync attribute-change helper

Adds InstanceActor one-shot waiter registry (fast-path + change-match + scheduled
timeout self-eviction), threads per-script timeout token through ScriptRuntimeContext,
and exposes Attributes.WaitAsync(value|predicate, timeout). Replaces handshake busy-poll.
Implements spec docs/plans/2026-06-17-waitfor-attribute-change-helper-spec.md §3-§5;
§6 routed variant + WaitForAsync + quality-only mode deferred.
This commit is contained in:
Joseph Doherty
2026-06-17 08:25:06 -04:00
parent b89d69a008
commit 75ffa09b8f
8 changed files with 731 additions and 4 deletions
@@ -73,6 +73,35 @@ public class AttributeAccessor
/// <returns>A task that represents the asynchronous operation.</returns>
public Task SetAsync(string key, object? value)
=> _ctx.SetAttribute(Resolve(key), AttributeValueCodec.Encode(value) ?? string.Empty);
/// <summary>
/// WaitForAttribute (spec §3-§5): waits event-driven until the attribute equals
/// <paramref name="targetValue"/> (value-equality, codec-normalized), bounded by
/// <paramref name="timeout"/>. Returns <c>true</c> if matched within the timeout,
/// <c>false</c> on timeout (no throw). Honors the script's execution-timeout token.
/// Scope/composition path resolution (<see cref="Resolve"/>) is applied just like
/// <see cref="GetAsync"/> / <see cref="SetAsync"/>.
/// </summary>
/// <param name="key">The attribute key (scope-resolved before the wait is registered).</param>
/// <param name="targetValue">The value to wait for (codec-encoded for comparison).</param>
/// <param name="timeout">How long to wait before returning false.</param>
/// <returns><c>true</c> on match within the timeout; <c>false</c> on timeout.</returns>
public Task<bool> WaitAsync(string key, object? targetValue, TimeSpan timeout)
=> _ctx.WaitAttribute(Resolve(key), AttributeValueCodec.Encode(targetValue), null, timeout);
/// <summary>
/// WaitForAttribute (spec §3-§5): predicate form — waits event-driven until
/// <paramref name="predicate"/> returns <c>true</c> for the attribute's current
/// value, bounded by <paramref name="timeout"/>. Site-local only (the predicate
/// is an in-process delegate). Returns <c>true</c> if matched within the timeout,
/// <c>false</c> on timeout (no throw). Scope/composition path resolution applies.
/// </summary>
/// <param name="key">The attribute key (scope-resolved before the wait is registered).</param>
/// <param name="predicate">The site-local predicate tested against the current value.</param>
/// <param name="timeout">How long to wait before returning false.</param>
/// <returns><c>true</c> on match within the timeout; <c>false</c> on timeout.</returns>
public Task<bool> WaitAsync(string key, Func<object?, bool> predicate, TimeSpan timeout)
=> _ctx.WaitAttribute(Resolve(key), null, predicate, timeout);
}
/// <summary>
@@ -46,6 +46,16 @@ public class ScriptRuntimeContext
private readonly ILogger _logger;
private readonly string _instanceName;
/// <summary>
/// WaitForAttribute (spec §4.3): the per-script execution-timeout token from
/// the owning <c>ScriptExecutionActor</c>/<c>AlarmExecutionActor</c>
/// (<c>cts.Token</c>). Bounds the <c>Attributes.WaitAsync</c> Ask so a script
/// that hits its own <c>ExecutionTimeoutSeconds</c> abandons the wait. Defaults
/// to <see cref="CancellationToken.None"/> for contexts that do not thread one
/// (legacy callers / tests / the alarm path when it has no CTS).
/// </summary>
private readonly CancellationToken _scriptTimeoutToken;
/// <summary>
/// WP-13: External system client for ExternalSystem.Call/CachedCall.
/// </summary>
@@ -194,6 +204,13 @@ public class ScriptRuntimeContext
/// <c>ILogger.LogError</c> + throw. When null the existing behaviour is
/// unchanged; all existing callers and tests remain source-compatible.
/// </param>
/// <param name="scriptTimeoutToken">
/// WaitForAttribute (spec §4.3): the per-script execution-timeout token
/// (<c>cts.Token</c> on the owning execution actor) used to bound
/// <c>Attributes.WaitAsync</c>. Defaults to
/// <see cref="CancellationToken.None"/> for callers / tests that do not
/// thread one — those waits are bounded only by their own timeout.
/// </param>
public ScriptRuntimeContext(
IActorRef instanceActor,
IActorRef self,
@@ -215,7 +232,8 @@ public class ScriptRuntimeContext
Guid? executionId = null,
Guid? parentExecutionId = null,
string? sourceNode = null,
ISiteEventLogger? siteEventLogger = null)
ISiteEventLogger? siteEventLogger = null,
CancellationToken scriptTimeoutToken = default)
{
_instanceActor = instanceActor;
_self = self;
@@ -245,6 +263,9 @@ public class ScriptRuntimeContext
_parentExecutionId = parentExecutionId;
// M2.12 (#25): optional — null when not wired (tests / AlarmExecutionActor).
_siteEventLogger = siteEventLogger;
// WaitForAttribute (spec §4.3): default(CancellationToken) == None when
// not threaded in — the WaitAsync Ask is then bounded only by its own timeout.
_scriptTimeoutToken = scriptTimeoutToken;
}
/// <summary>
@@ -297,7 +318,11 @@ public class ScriptRuntimeContext
// …parented to THIS run's execution id (the spawner).
parentExecutionId: _executionId,
sourceNode: _sourceNode,
siteEventLogger: _siteEventLogger);
siteEventLogger: _siteEventLogger,
// WaitForAttribute (spec §4.3): an inline shared-script call shares the
// parent run's execution-timeout token so a WaitAsync inside the shared
// script is bounded by the SAME script deadline.
scriptTimeoutToken: _scriptTimeoutToken);
}
/// <summary>
@@ -360,6 +385,42 @@ public class ScriptRuntimeContext
return response.Value;
}
/// <summary>
/// WaitForAttribute (spec §3-§5): waits event-driven for an attribute to reach
/// a value (encoded-equality), satisfy a site-local predicate, or change at all,
/// bounded by <paramref name="timeout"/>. Returns <c>true</c> if matched within
/// the timeout, <c>false</c> on timeout — NEVER throws on timeout. The backing
/// <c>Attributes.WaitAsync</c> for the accessor.
///
/// <para>
/// The Ask is bounded by the script's own execution-timeout token (§4.3): a
/// script that hits its <c>ExecutionTimeoutSeconds</c> abandons the wait. The
/// Ask timeout is the wait timeout plus a small <see cref="_askTimeout"/> slack
/// so the InstanceActor's own scheduled timeout reply is the authoritative path
/// for the false/timed-out outcome, not the Ask deadline.
/// </para>
/// </summary>
/// <param name="name">The scope-resolved attribute name to wait on.</param>
/// <param name="targetValueEncoded">
/// The codec-encoded target value; null (with null <paramref name="predicate"/>)
/// means "any change".
/// </param>
/// <param name="predicate">Site-local predicate; null when the encoded target is used.</param>
/// <param name="timeout">How long to wait before returning false.</param>
/// <returns><c>true</c> on match within the timeout; <c>false</c> on timeout.</returns>
public async Task<bool> WaitAttribute(
string name, string? targetValueEncoded, Func<object?, bool>? predicate, TimeSpan timeout)
{
var cid = Guid.NewGuid().ToString();
var req = new WaitForAttributeRequest(
cid, _instanceName, name, targetValueEncoded, predicate, timeout, DateTimeOffset.UtcNow);
var resp = await _instanceActor.Ask<WaitForAttributeResponse>(
req, timeout + _askTimeout, _scriptTimeoutToken);
return resp.Matched;
}
/// <summary>
/// Sets an attribute value. For data-connected attributes the Instance Actor
/// forwards the write to the DCL, which writes the physical device; the