feat(siteruntime): event-driven Attributes.WriteBatchAndWaitAsync (batched DCL write + trigger + existing WaitForAttribute waiter) + compile mirror

This commit is contained in:
Joseph Doherty
2026-06-17 12:13:02 -04:00
parent e8db6c71a8
commit 0e989c867d
8 changed files with 363 additions and 0 deletions
@@ -188,6 +188,12 @@ public class InstanceActor : ReceiveActor
Receive<WaitForAttributeRequest>(HandleWaitForAttribute);
Receive<WaitForAttributeTimeout>(HandleWaitForAttributeTimeout);
// Batch write + (event-driven) wait: resolves a set of data-sourced
// attributes to one DCL connection and forwards a single WriteTagBatchRequest.
// Backs the script-facing Attributes.WriteBatchAndWaitAsync helper; the wait
// half is the WaitForAttribute waiter above.
Receive<WriteAttributeBatchRequest>(HandleWriteAttributeBatch);
// Handle tag value updates from DCL — convert to AttributeValueChanged
Receive<TagValueUpdate>(HandleTagValueUpdate);
Receive<SubscribeTagsResponse>(_ => { }); // Ack from DCL subscribe — no action needed
@@ -499,6 +505,118 @@ public class InstanceActor : ReceiveActor
}).PipeTo(caller);
}
/// <summary>
/// Batch write: resolves a SET of data-sourced attributes (and an optional trigger
/// attribute) to their device bindings, enforces a single data connection across the
/// whole batch, decodes List values (same rule as <see cref="HandleSetDataAttribute"/>),
/// and forwards ONE <see cref="WriteTagBatchRequest"/> to the DCL — replacing N
/// sequential per-attribute writes with a single gateway round-trip. The write outcome
/// is returned synchronously to the caller; the EVENT-DRIVEN wait for the response
/// attribute is performed separately by the caller via the existing WaitForAttribute
/// waiter (this handler does not wait). Resolution mirrors the data-sourced
/// SetAttribute path: an attribute is data-sourced only when it resolves AND has both a
/// <see cref="ResolvedAttribute.DataSourceReference"/> and a
/// <see cref="ResolvedAttribute.BoundDataConnectionName"/>.
/// </summary>
private void HandleWriteAttributeBatch(WriteAttributeBatchRequest request)
{
var caller = Sender;
var cid = request.CorrelationId;
if (_dclManager == null)
{
caller.Tell(new WriteAttributeBatchResponse(
cid, false, "Data Connection Layer not available for write."));
return;
}
var values = new Dictionary<string, object?>();
string? connName = null;
foreach (var kv in request.AttributeEncodedValues)
{
if (!_resolvedAttributeByName.TryGetValue(kv.Key, out var resolved)
|| string.IsNullOrEmpty(resolved.DataSourceReference)
|| string.IsNullOrEmpty(resolved.BoundDataConnectionName))
{
caller.Tell(new WriteAttributeBatchResponse(
cid, false, $"Attribute '{kv.Key}' is not a data-sourced attribute."));
return;
}
if (connName == null)
connName = resolved.BoundDataConnectionName;
else if (!string.Equals(connName, resolved.BoundDataConnectionName, StringComparison.Ordinal))
{
caller.Tell(new WriteAttributeBatchResponse(
cid, false, "Batch write spans multiple data connections; not supported."));
return;
}
object? wv = kv.Value;
// MV: a data-sourced List attribute's encoded value is the canonical JSON
// array string — decode it to a typed List<T> so the DCL/Variant write
// produces a real array (same poison-rejection rule as HandleSetDataAttribute).
if (IsListAttribute(resolved) && !string.IsNullOrWhiteSpace(kv.Value))
{
var decoded = DecodeAttributeValue(resolved, kv.Value);
if (decoded == null)
{
caller.Tell(new WriteAttributeBatchResponse(
cid, false, $"Invalid list value for attribute '{kv.Key}'"));
return;
}
wv = decoded;
}
values[resolved.DataSourceReference!] = wv;
}
string? triggerPath = null;
object? triggerVal = null;
if (!string.IsNullOrEmpty(request.TriggerAttribute))
{
if (!_resolvedAttributeByName.TryGetValue(request.TriggerAttribute, out var tr)
|| string.IsNullOrEmpty(tr.DataSourceReference)
|| string.IsNullOrEmpty(tr.BoundDataConnectionName))
{
caller.Tell(new WriteAttributeBatchResponse(
cid, false, $"Trigger attribute '{request.TriggerAttribute}' is not data-sourced."));
return;
}
if (connName != null && !string.Equals(connName, tr.BoundDataConnectionName, StringComparison.Ordinal))
{
caller.Tell(new WriteAttributeBatchResponse(
cid, false, "Trigger attribute is on a different data connection."));
return;
}
connName ??= tr.BoundDataConnectionName;
triggerPath = tr.DataSourceReference;
triggerVal = request.TriggerEncodedValue;
}
if (connName == null)
{
caller.Tell(new WriteAttributeBatchResponse(cid, false, "No attributes to write."));
return;
}
var dclReq = new WriteTagBatchRequest(
cid, connName!, values, triggerPath, triggerVal, DateTimeOffset.UtcNow);
// Ask the DCL and pipe the batch outcome back to the original caller. The DCL
// bounds its own write with WriteTimeout; this Ask is bounded a bit longer so a
// device timeout is surfaced as a DCL failure rather than an Ask timeout.
_dclManager.Ask<WriteTagBatchResponse>(dclReq, TimeSpan.FromSeconds(30))
.ContinueWith(t => t.IsCompletedSuccessfully
? new WriteAttributeBatchResponse(cid, t.Result.Success, t.Result.ErrorMessage)
: new WriteAttributeBatchResponse(
cid, false, t.Exception?.GetBaseException().Message ?? "DCL batch write timed out"))
.PipeTo(caller);
}
/// <summary>
/// WP-15: Routes script call requests to the appropriate Script Actor.
/// Uses Ask pattern (WP-22).