feat(siteruntime): event-driven Attributes.WriteBatchAndWaitAsync (batched DCL write + trigger + existing WaitForAttribute waiter) + compile mirror
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -74,6 +74,40 @@ public class AttributeAccessor
|
||||
public Task SetAsync(string key, object? value)
|
||||
=> _ctx.SetAttribute(Resolve(key), AttributeValueCodec.Encode(value) ?? string.Empty);
|
||||
|
||||
/// <summary>
|
||||
/// Writes a SET of data-sourced attributes to the device in ONE gateway batch
|
||||
/// round-trip, then writes <paramref name="flagKey"/>=<paramref name="flagValue"/>,
|
||||
/// then waits EVENT-DRIVEN (reusing the existing <c>WaitForAttribute</c> waiter — not a
|
||||
/// poll) for <paramref name="responseKey"/> to reach <paramref name="responseValue"/>,
|
||||
/// bounded by <paramref name="timeout"/>. Replaces N sequential per-attribute writes
|
||||
/// with one batched call before the wait. All keys are scope-resolved (<see cref="Resolve"/>)
|
||||
/// and all values codec-encoded just like the other accessors. Returns <c>true</c> if
|
||||
/// the response was observed within the timeout, <c>false</c> on timeout (no throw on
|
||||
/// timeout); throws <see cref="System.InvalidOperationException"/> if the batch/trigger
|
||||
/// write itself fails.
|
||||
/// </summary>
|
||||
/// <param name="values">Attribute key → value to batch-write (keys scope-resolved, values codec-encoded).</param>
|
||||
/// <param name="flagKey">Trigger attribute key written AFTER the batch.</param>
|
||||
/// <param name="flagValue">Value to write to the trigger.</param>
|
||||
/// <param name="responseKey">Attribute key to wait on.</param>
|
||||
/// <param name="responseValue">Target value to wait for (<c>null</c> ⇒ any change).</param>
|
||||
/// <param name="timeout">How long to wait before returning false.</param>
|
||||
/// <returns><c>true</c> on response match within the timeout; <c>false</c> on timeout.</returns>
|
||||
public Task<bool> WriteBatchAndWaitAsync(
|
||||
IReadOnlyDictionary<string, object?> values, string flagKey, object? flagValue,
|
||||
string responseKey, object? responseValue, TimeSpan timeout)
|
||||
{
|
||||
var encoded = new Dictionary<string, string?>(values.Count);
|
||||
foreach (var kv in values)
|
||||
encoded[Resolve(kv.Key)] = AttributeValueCodec.Encode(kv.Value);
|
||||
|
||||
return _ctx.WriteBatchAndWait(
|
||||
encoded,
|
||||
Resolve(flagKey), AttributeValueCodec.Encode(flagValue),
|
||||
Resolve(responseKey), AttributeValueCodec.Encode(responseValue),
|
||||
timeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WaitForAttribute (spec §3-§5): waits event-driven until the attribute equals
|
||||
/// <paramref name="targetValue"/> (value-equality, codec-normalized), bounded by
|
||||
|
||||
@@ -525,6 +525,44 @@ public class ScriptRuntimeContext
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a SET of data-sourced attributes to the device in ONE DCL batch round-trip,
|
||||
/// then writes a trigger flag, then waits EVENT-DRIVEN (reusing the existing
|
||||
/// <see cref="WaitAttribute"/> waiter — not a poll) for <paramref name="responseAttr"/>
|
||||
/// to reach <paramref name="responseEncoded"/>, bounded by <paramref name="timeout"/>.
|
||||
/// Replaces N sequential per-attribute writes with a single gateway call before the
|
||||
/// wait. Throws <see cref="InvalidOperationException"/> if the write/trigger leg fails
|
||||
/// (resolution error, multi-connection batch, device error); returns the wait result
|
||||
/// otherwise (true = matched, false = timeout, never throws on timeout).
|
||||
/// </summary>
|
||||
/// <param name="encodedValues">Scope-resolved attribute name → codec-encoded value to batch-write.</param>
|
||||
/// <param name="triggerAttr">Scope-resolved trigger attribute name written AFTER the batch.</param>
|
||||
/// <param name="triggerEncoded">Codec-encoded value for the trigger.</param>
|
||||
/// <param name="responseAttr">Scope-resolved attribute to wait on.</param>
|
||||
/// <param name="responseEncoded">Codec-encoded target value (null ⇒ any change).</param>
|
||||
/// <param name="timeout">How long to wait for the response before returning false.</param>
|
||||
/// <returns><c>true</c> on response match within the timeout; <c>false</c> on timeout.</returns>
|
||||
public async Task<bool> WriteBatchAndWait(
|
||||
IReadOnlyDictionary<string, string?> encodedValues, string triggerAttr, string? triggerEncoded,
|
||||
string responseAttr, string? responseEncoded, TimeSpan timeout)
|
||||
{
|
||||
var cid = Guid.NewGuid().ToString();
|
||||
var batchReq = new WriteAttributeBatchRequest(
|
||||
cid, _instanceName, encodedValues, triggerAttr, triggerEncoded, DateTimeOffset.UtcNow);
|
||||
|
||||
// 35s: the InstanceActor's DCL Ask is internally bounded at 30s, so allow a small
|
||||
// margin so the DCL's own typed failure/timeout reply is the one we observe rather
|
||||
// than an AskTimeoutException here. Honors the script execution-timeout token.
|
||||
var batchResp = await _instanceActor.Ask<WriteAttributeBatchResponse>(
|
||||
batchReq, TimeSpan.FromSeconds(35), _scriptTimeoutToken);
|
||||
|
||||
if (!batchResp.Success)
|
||||
throw new InvalidOperationException(
|
||||
$"WriteBatchAndWait write failed: {batchResp.ErrorMessage}");
|
||||
|
||||
return await WaitAttribute(responseAttr, responseEncoded, null, timeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calls a sibling script on the same instance by name (Ask pattern).
|
||||
/// WP-20: Enforces recursion limit.
|
||||
|
||||
Reference in New Issue
Block a user