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
@@ -0,0 +1,63 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
/// <summary>
/// Request to wait, event-driven, until an attribute reaches a value (or any
/// value satisfying a predicate), bounded by a timeout — the backing protocol for
/// the script-facing <c>Attributes.WaitAsync</c> helper.
///
/// <para>
/// <b>Site-local only.</b> The optional <see cref="Predicate"/> is a non-serializable
/// in-process delegate, so this message MUST flow only within a single site node's
/// actor system (script execution → Instance Actor). It is never sent across the
/// ClusterClient / gRPC boundary. The value-equality form (<see cref="TargetValueEncoded"/>)
/// would serialize, but the routed/inbound variant is deliberately out of scope here.
/// </para>
/// </summary>
/// <param name="CorrelationId">Per-wait correlation id; keys the waiter registry and the timeout self-message.</param>
/// <param name="InstanceName">The instance this wait targets.</param>
/// <param name="AttributeName">The attribute to watch — already scope-resolved by the accessor.</param>
/// <param name="TargetValueEncoded">
/// The codec-encoded target value (<c>AttributeValueCodec.Encode(target)</c>). A
/// match compares the codec-encoded form of the current value against this string.
/// When both this and <see cref="Predicate"/> are null the wait matches on ANY change.
/// </param>
/// <param name="Predicate">
/// Site-local predicate tested against the raw (decoded) current value. Mutually
/// exclusive with <see cref="TargetValueEncoded"/> — null when the encoded target is used.
/// </param>
/// <param name="Timeout">How long to wait before self-evicting with a timeout reply.</param>
/// <param name="OccurredAtUtc">When the request was issued (UTC).</param>
public record WaitForAttributeRequest(
string CorrelationId,
string InstanceName,
string AttributeName,
string? TargetValueEncoded,
Func<object?, bool>? Predicate,
TimeSpan Timeout,
DateTimeOffset OccurredAtUtc);
/// <summary>
/// Reply to a <see cref="WaitForAttributeRequest"/>. Exactly one of
/// <see cref="Matched"/> / <see cref="TimedOut"/> is set on the happy paths;
/// <see cref="ErrorMessage"/> is populated only on the defensive cap-exceeded path.
/// </summary>
/// <param name="CorrelationId">Echoes the request's correlation id.</param>
/// <param name="Matched">True when the attribute reached the target/predicate within the timeout.</param>
/// <param name="Value">The matched value (null on timeout / error).</param>
/// <param name="Quality">The attribute quality at match time (empty on timeout / error).</param>
/// <param name="TimedOut">True when the timeout fired before a match.</param>
/// <param name="ErrorMessage">Non-null only when the wait was refused (e.g. per-instance waiter cap exceeded).</param>
public record WaitForAttributeResponse(
string CorrelationId,
bool Matched,
object? Value,
string Quality,
bool TimedOut,
string? ErrorMessage = null);
/// <summary>
/// Internal self-message scheduled by the Instance Actor to fire a waiter's
/// timeout. Site-local only; never crosses a cluster boundary.
/// </summary>
/// <param name="CorrelationId">The waiter whose timeout fired.</param>
public record WaitForAttributeTimeout(string CorrelationId);