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
@@ -68,6 +68,18 @@ public class InstanceActor : ReceiveActor
// mirroring the rest of the actor's by-name dictionaries).
private readonly Dictionary<string, ResolvedAttribute> _resolvedAttributeByName = new();
// WaitForAttribute (spec §4.2): one-shot waiter registry keyed by the
// request CorrelationId. Each entry holds the watched attribute name, the
// match test (decoded target equality OR a site-local predicate), the
// original Sender to reply to, and the scheduled-timeout handle so a match
// can cancel it. Single-threaded actor access — no locking needed.
private readonly Dictionary<string, PendingWait> _attributeWaiters = new();
// WaitForAttribute: defensive per-instance cap so a script leaking waiters
// in a loop cannot grow the registry without bound. Exceeding it refuses the
// wait with an error reply rather than registering.
private const int MaxAttributeWaiters = 100;
// DCL manager actor reference for subscribing to tag values
private readonly IActorRef? _dclManager;
// Maps each tag path to every attribute canonical name that references it.
@@ -170,6 +182,12 @@ public class InstanceActor : ReceiveActor
// WP-22/23: Handle attribute value changes from DCL (Tell pattern)
Receive<AttributeValueChanged>(HandleAttributeValueChanged);
// WaitForAttribute (spec §4.2): event-driven "wait for value" waiter
// registration + its scheduled-timeout self-message. Both flow only
// site-locally (the predicate variant carries a non-serializable delegate).
Receive<WaitForAttributeRequest>(HandleWaitForAttribute);
Receive<WaitForAttributeTimeout>(HandleWaitForAttributeTimeout);
// Handle tag value updates from DCL — convert to AttributeValueChanged
Receive<TagValueUpdate>(HandleTagValueUpdate);
Receive<SubscribeTagsResponse>(_ => { }); // Ack from DCL subscribe — no action needed
@@ -519,6 +537,80 @@ public class InstanceActor : ReceiveActor
PublishAndNotifyChildren(changed);
}
/// <summary>
/// WaitForAttribute (spec §4.2): registers a one-shot event-driven waiter for
/// an attribute to reach a value (encoded-equality), satisfy a site-local
/// predicate, or change at all. The current-value fast-path and the
/// change-handling in <see cref="HandleAttributeValueChanged"/> both run on
/// this single-threaded actor, so a value that flips between "read current"
/// and "register" cannot be missed (spec §5).
/// </summary>
private void HandleWaitForAttribute(WaitForAttributeRequest req)
{
// Capture the sender immediately — Sender is invalid once we schedule /
// return and a later message arrives.
var replyer = Sender;
// Build the match test: explicit predicate wins; else null encoded target
// means "any change"; else compare the codec-encoded current value to the
// encoded target (avoids needing the attribute's DataType to decode).
Func<object?, bool> test;
if (req.Predicate is not null)
{
test = req.Predicate;
}
else if (req.TargetValueEncoded is null)
{
test = _ => true;
}
else
{
var target = req.TargetValueEncoded;
test = v => string.Equals(
AttributeValueCodec.Encode(v), target, StringComparison.Ordinal);
}
// Fast path: the current value already satisfies the test → reply now.
if (_attributes.TryGetValue(req.AttributeName, out var current) && test(current))
{
_attributeQualities.TryGetValue(req.AttributeName, out var quality);
replyer.Tell(new WaitForAttributeResponse(
req.CorrelationId, Matched: true, current, quality ?? "Good", TimedOut: false));
return;
}
// Defensive cap: refuse rather than register if the instance already has
// too many concurrent waiters (guards against a script leaking waiters).
if (_attributeWaiters.Count >= MaxAttributeWaiters)
{
replyer.Tell(new WaitForAttributeResponse(
req.CorrelationId, Matched: false, null, "", TimedOut: false,
ErrorMessage: "Too many concurrent attribute waiters on this instance"));
return;
}
// Register and schedule the self-evicting timeout (NativeAlarmActor idiom).
var handle = Context.System.Scheduler.ScheduleTellOnceCancelable(
req.Timeout, Self, new WaitForAttributeTimeout(req.CorrelationId), Self);
_attributeWaiters[req.CorrelationId] =
new PendingWait(req.AttributeName, test, replyer, handle);
}
/// <summary>
/// WaitForAttribute (spec §4.2): the scheduled timeout fired for a waiter that
/// never matched. If still registered (a match would have removed + canceled
/// it), reply TimedOut and evict it.
/// </summary>
private void HandleWaitForAttributeTimeout(WaitForAttributeTimeout msg)
{
if (_attributeWaiters.Remove(msg.CorrelationId, out var pending))
{
pending.Replyer.Tell(new WaitForAttributeResponse(
msg.CorrelationId, Matched: false, null, "", TimedOut: true));
}
}
/// <summary>
/// Handles tag value updates from DCL. Maps the tag path back to the attribute
/// canonical name and converts to an AttributeValueChanged for unified processing.
@@ -924,6 +1016,41 @@ public class InstanceActor : ReceiveActor
{
alarmActor.Tell(changed);
}
// WaitForAttribute (spec §4.2): re-evaluate any waiters on THIS attribute.
// PublishAndNotifyChildren is THE single choke point for every value change
// — both the DCL ingest path (HandleAttributeValueChanged) and the static
// write path (HandleSetStaticAttributeCore) call it AFTER updating
// _attributes, so changed.Value is the just-applied current value. Iterate a
// snapshot so satisfied waiters can be removed during the loop; each match
// cancels its scheduled timeout (so no stray WaitForAttributeTimeout follows)
// and replies Matched=true.
ResolveMatchedWaiters(changed);
}
/// <summary>
/// WaitForAttribute (spec §4.2): fires every registered waiter on
/// <paramref name="changed"/>'s attribute whose test now passes against the
/// just-applied value — cancelling its timeout, replying Matched, and removing
/// it from the registry. A no-op when there are no waiters.
/// </summary>
private void ResolveMatchedWaiters(AttributeValueChanged changed)
{
if (_attributeWaiters.Count == 0)
return;
var matched = _attributeWaiters
.Where(kvp => kvp.Value.AttributeName == changed.AttributeName
&& kvp.Value.Test(changed.Value))
.ToList();
foreach (var (cid, pending) in matched)
{
pending.Timeout.Cancel();
pending.Replyer.Tell(new WaitForAttributeResponse(
cid, Matched: true, changed.Value, changed.Quality, TimedOut: false));
_attributeWaiters.Remove(cid);
}
}
/// <summary>
@@ -1202,4 +1329,17 @@ public class InstanceActor : ReceiveActor
/// Internal message for async override loading result.
/// </summary>
internal record LoadOverridesResult(Dictionary<string, string> Overrides, string? Error);
/// <summary>
/// WaitForAttribute (spec §4.2): one registered, not-yet-satisfied waiter.
/// </summary>
/// <param name="AttributeName">The attribute this waiter watches (scope-resolved).</param>
/// <param name="Test">The match test (decoded-target equality OR site-local predicate OR any-change).</param>
/// <param name="Replyer">The original sender to reply to on match / timeout.</param>
/// <param name="Timeout">The scheduled timeout handle, canceled on match.</param>
private sealed record PendingWait(
string AttributeName,
Func<object?, bool> Test,
IActorRef Replyer,
ICancelable Timeout);
}