perf(site-runtime): batch Route.To().GetAttributes/SetAttributes into one Instance Actor message (review 2026-10-21 01/SR-P4)
One Ask per attribute became one site-local InstanceGet/SetAttributesRequest answered in a single pass, keeping the per-attribute GetAttributeResponse / SetStaticAttributeResponse outcomes. The single-write path is refactored to a Task<SetStaticAttributeResponse> outcome shared by both paths; static writes and rejections still reply synchronously.
This commit is contained in:
@@ -321,6 +321,11 @@ public class InstanceActor : ReceiveActor
|
||||
// Handle attribute queries (Tell pattern -- sender gets response)
|
||||
Receive<GetAttributeRequest>(HandleGetAttribute);
|
||||
|
||||
// Review 2026-10-21 01/SR-P4: batched reads/writes for Route.To().GetAttributes() /
|
||||
// SetAttributes() — one message per call instead of one Ask per attribute.
|
||||
Receive<InstanceGetAttributesRequest>(HandleGetAttributes);
|
||||
Receive<InstanceSetAttributesRequest>(HandleSetAttributes);
|
||||
|
||||
// Handle static attribute writes
|
||||
Receive<SetStaticAttributeCommand>(HandleSetStaticAttribute);
|
||||
|
||||
@@ -509,18 +514,75 @@ public class InstanceActor : ReceiveActor
|
||||
/// <summary>
|
||||
/// Returns the current attribute value. Uses Tell pattern; sender gets the response.
|
||||
/// </summary>
|
||||
private void HandleGetAttribute(GetAttributeRequest request)
|
||||
private void HandleGetAttribute(GetAttributeRequest request) =>
|
||||
Sender.Tell(ReadAttribute(request.CorrelationId, request.AttributeName));
|
||||
|
||||
/// <summary>The single-attribute read shared by the single and batched paths.</summary>
|
||||
private GetAttributeResponse ReadAttribute(string correlationId, string attributeName)
|
||||
{
|
||||
var found = _attributes.TryGetValue(request.AttributeName, out var value);
|
||||
_attributeQualities.TryGetValue(request.AttributeName, out var quality);
|
||||
Sender.Tell(new GetAttributeResponse(
|
||||
request.CorrelationId,
|
||||
var found = _attributes.TryGetValue(attributeName, out var value);
|
||||
_attributeQualities.TryGetValue(attributeName, out var quality);
|
||||
return new GetAttributeResponse(
|
||||
correlationId,
|
||||
_instanceUniqueName,
|
||||
request.AttributeName,
|
||||
attributeName,
|
||||
value,
|
||||
found,
|
||||
quality ?? "Good",
|
||||
DateTimeOffset.UtcNow));
|
||||
DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 01/SR-P4: answers every requested attribute in one pass, one
|
||||
/// <see cref="GetAttributeResponse"/> per name (index-aligned), from a single consistent
|
||||
/// point in this actor's mailbox.
|
||||
/// </summary>
|
||||
private void HandleGetAttributes(InstanceGetAttributesRequest request)
|
||||
{
|
||||
var results = new GetAttributeResponse[request.AttributeNames.Count];
|
||||
for (var i = 0; i < results.Length; i++)
|
||||
results[i] = ReadAttribute(request.CorrelationId, request.AttributeNames[i]);
|
||||
Sender.Tell(new InstanceGetAttributesResponse(request.CorrelationId, results));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 01/SR-P4: applies each write in order exactly as the equivalent single
|
||||
/// <see cref="SetStaticAttributeCommand"/> would be applied (static writes take effect
|
||||
/// synchronously here, data-sourced writes are issued to the DCL concurrently), then replies
|
||||
/// once with every per-write outcome — immediately when nothing is outstanding, otherwise
|
||||
/// when the last DCL write answers. Each data write keeps its own 30 s DCL bound.
|
||||
/// </summary>
|
||||
private void HandleSetAttributes(InstanceSetAttributesRequest request)
|
||||
{
|
||||
var caller = Sender;
|
||||
var outcomes = new Task<SetStaticAttributeResponse>[request.Writes.Count];
|
||||
for (var i = 0; i < outcomes.Length; i++)
|
||||
outcomes[i] = ApplySetAttribute(request.Writes[i]);
|
||||
|
||||
if (outcomes.All(t => t.IsCompleted))
|
||||
{
|
||||
caller.Tell(new InstanceSetAttributesResponse(
|
||||
request.CorrelationId, outcomes.Select(t => t.Result).ToArray()));
|
||||
return;
|
||||
}
|
||||
|
||||
// Every outcome task is a never-faulting ContinueWith projection (see
|
||||
// ApplySetDataAttribute), so WhenAll always yields the full result array.
|
||||
Task.WhenAll(outcomes)
|
||||
.ContinueWith(t => new InstanceSetAttributesResponse(request.CorrelationId, t.Result))
|
||||
.PipeTo(caller);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replies with a write outcome: synchronously when it is already known (every static write
|
||||
/// and every rejection — the exact pre-batching timing), otherwise when the DCL answers.
|
||||
/// </summary>
|
||||
private static void ReplyWhenDone(Task<SetStaticAttributeResponse> outcome, IActorRef caller)
|
||||
{
|
||||
if (outcome.IsCompleted)
|
||||
caller.Tell(outcome.Result);
|
||||
else
|
||||
outcome.PipeTo(caller);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -538,7 +600,15 @@ public class InstanceActor : ReceiveActor
|
||||
///
|
||||
/// Either way the caller receives a <see cref="SetStaticAttributeResponse"/>.
|
||||
/// </summary>
|
||||
private void HandleSetStaticAttribute(SetStaticAttributeCommand command)
|
||||
private void HandleSetStaticAttribute(SetStaticAttributeCommand command) =>
|
||||
ReplyWhenDone(ApplySetAttribute(command), Sender);
|
||||
|
||||
/// <summary>
|
||||
/// Applies one attribute write (see <see cref="HandleSetStaticAttribute"/>) and returns its
|
||||
/// outcome — completed for a static write or a rejection, pending for a data-sourced write
|
||||
/// until the DCL answers. Shared by the single and batched write paths (01/SR-P4).
|
||||
/// </summary>
|
||||
private Task<SetStaticAttributeResponse> ApplySetAttribute(SetStaticAttributeCommand command)
|
||||
{
|
||||
// O(1) lookup via the existing hot-path index instead of a linear scan over
|
||||
// _configuration.Attributes.
|
||||
@@ -558,14 +628,13 @@ public class InstanceActor : ReceiveActor
|
||||
_logger.LogWarning(
|
||||
"SetAttribute rejected — attribute '{Attribute}' is not defined on instance '{Instance}'",
|
||||
command.AttributeName, _instanceUniqueName);
|
||||
Sender.Tell(new SetStaticAttributeResponse(
|
||||
return Task.FromResult(new SetStaticAttributeResponse(
|
||||
command.CorrelationId,
|
||||
_instanceUniqueName,
|
||||
command.AttributeName,
|
||||
false,
|
||||
$"Unknown attribute '{command.AttributeName}'",
|
||||
DateTimeOffset.UtcNow));
|
||||
return;
|
||||
}
|
||||
|
||||
var isDataSourced =
|
||||
@@ -573,19 +642,16 @@ public class InstanceActor : ReceiveActor
|
||||
&& !string.IsNullOrEmpty(resolved.Attribute.BoundDataConnectionName);
|
||||
|
||||
if (isDataSourced)
|
||||
{
|
||||
HandleSetDataAttribute(command, resolved);
|
||||
return;
|
||||
}
|
||||
return ApplySetDataAttribute(command, resolved);
|
||||
|
||||
HandleSetStaticAttributeCore(command);
|
||||
return Task.FromResult(ApplySetStaticAttributeCore(command));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Static attribute write: updates in-memory state, publishes the change,
|
||||
/// persists the override to SQLite, and replies with success.
|
||||
/// persists the override to SQLite, and returns the outcome to reply with.
|
||||
/// </summary>
|
||||
private void HandleSetStaticAttributeCore(SetStaticAttributeCommand command)
|
||||
private SetStaticAttributeResponse ApplySetStaticAttributeCore(SetStaticAttributeCommand command)
|
||||
{
|
||||
// command.Value is the canonical form — a plain string for scalars,
|
||||
// a JSON array string for List attributes. For a List attribute we store
|
||||
@@ -613,14 +679,13 @@ public class InstanceActor : ReceiveActor
|
||||
_logger.LogWarning(
|
||||
"SetAttribute rejected — value for List attribute '{Attribute}' on instance '{Instance}' is not a valid list",
|
||||
command.AttributeName, _instanceUniqueName);
|
||||
Sender.Tell(new SetStaticAttributeResponse(
|
||||
return new SetStaticAttributeResponse(
|
||||
command.CorrelationId,
|
||||
_instanceUniqueName,
|
||||
command.AttributeName,
|
||||
false,
|
||||
$"Invalid list value for attribute '{command.AttributeName}'",
|
||||
DateTimeOffset.UtcNow));
|
||||
return;
|
||||
DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
_attributes[command.AttributeName] = decoded;
|
||||
@@ -658,9 +723,9 @@ public class InstanceActor : ReceiveActor
|
||||
// of when the buffered write lands.
|
||||
MarkDirtyStaticOverride(command.AttributeName, command.Value);
|
||||
|
||||
Sender.Tell(new SetStaticAttributeResponse(
|
||||
return new SetStaticAttributeResponse(
|
||||
command.CorrelationId, _instanceUniqueName, command.AttributeName,
|
||||
true, null, DateTimeOffset.UtcNow));
|
||||
true, null, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -728,14 +793,14 @@ public class InstanceActor : ReceiveActor
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Data-sourced attribute write: forwards a write request to the DCL and pipes
|
||||
/// the device write result back to the caller. The in-memory value is left
|
||||
/// Data-sourced attribute write: forwards a write request to the DCL and returns
|
||||
/// the device write outcome (a task that never faults). The in-memory value is left
|
||||
/// untouched (it is refreshed by the subscription when the device confirms);
|
||||
/// no static override is persisted for a data-sourced attribute.
|
||||
/// </summary>
|
||||
private void HandleSetDataAttribute(SetStaticAttributeCommand command, AttributeIndexEntry resolved)
|
||||
private Task<SetStaticAttributeResponse> ApplySetDataAttribute(
|
||||
SetStaticAttributeCommand command, AttributeIndexEntry resolved)
|
||||
{
|
||||
var caller = Sender;
|
||||
var correlationId = command.CorrelationId;
|
||||
var attributeName = command.AttributeName;
|
||||
var instanceName = _instanceUniqueName;
|
||||
@@ -745,10 +810,9 @@ public class InstanceActor : ReceiveActor
|
||||
_logger.LogWarning(
|
||||
"SetAttribute on data-sourced attribute {Instance}.{Attribute} cannot be routed — no DCL manager configured",
|
||||
instanceName, attributeName);
|
||||
caller.Tell(new SetStaticAttributeResponse(
|
||||
return Task.FromResult(new SetStaticAttributeResponse(
|
||||
correlationId, instanceName, attributeName, false,
|
||||
"Data Connection Layer not available for write.", DateTimeOffset.UtcNow));
|
||||
return;
|
||||
}
|
||||
|
||||
// MV: for a data-sourced List attribute the incoming command.Value is
|
||||
@@ -758,7 +822,7 @@ public class InstanceActor : ReceiveActor
|
||||
// List<T> so the DCL/Variant write produces a real array. A non-empty value
|
||||
// that fails to decode (malformed JSON / bad element) is poison — reject the
|
||||
// write rather than forward garbage to the device (mirrors the static-path
|
||||
// rejection in HandleSetStaticAttributeCore). Scalars are unchanged.
|
||||
// rejection in ApplySetStaticAttributeCore). Scalars are unchanged.
|
||||
object? writeValue = command.Value;
|
||||
if (resolved.IsList && !string.IsNullOrWhiteSpace(command.Value))
|
||||
{
|
||||
@@ -769,10 +833,9 @@ public class InstanceActor : ReceiveActor
|
||||
_logger.LogWarning(
|
||||
"SetAttribute rejected — value for data-sourced List attribute '{Attribute}' on instance '{Instance}' is not a valid list",
|
||||
attributeName, instanceName);
|
||||
caller.Tell(new SetStaticAttributeResponse(
|
||||
return Task.FromResult(new SetStaticAttributeResponse(
|
||||
correlationId, instanceName, attributeName, false,
|
||||
$"Invalid list value for attribute '{attributeName}'", DateTimeOffset.UtcNow));
|
||||
return;
|
||||
}
|
||||
|
||||
writeValue = decoded;
|
||||
@@ -801,9 +864,9 @@ public class InstanceActor : ReceiveActor
|
||||
// touch _traceArms directly — it Tells self instead.
|
||||
var self = Self;
|
||||
|
||||
// Ask the DCL and pipe the result back to the original caller. The DCL
|
||||
// Ask the DCL; the outcome is replied to the original caller. The DCL
|
||||
// returns the failure synchronously so the script can handle it.
|
||||
_dclManager.Ask<WriteTagResponse>(writeRequest, TimeSpan.FromSeconds(30))
|
||||
return _dclManager.Ask<WriteTagResponse>(writeRequest, TimeSpan.FromSeconds(30))
|
||||
.ContinueWith(t =>
|
||||
{
|
||||
var succeeded = t.IsCompletedSuccessfully && t.Result.Success;
|
||||
@@ -819,13 +882,13 @@ public class InstanceActor : ReceiveActor
|
||||
correlationId, instanceName, attributeName, false,
|
||||
t.Exception?.GetBaseException().Message ?? "DCL write timed out",
|
||||
DateTimeOffset.UtcNow);
|
||||
}).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"/>),
|
||||
/// whole batch, decodes List values (same rule as <see cref="ApplySetDataAttribute"/>),
|
||||
/// 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
|
||||
@@ -873,7 +936,7 @@ public class InstanceActor : ReceiveActor
|
||||
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).
|
||||
// produces a real array (same poison-rejection rule as ApplySetDataAttribute).
|
||||
if (resolved.IsList && !string.IsNullOrWhiteSpace(kv.Value))
|
||||
{
|
||||
var decoded = DecodeAttributeValue(
|
||||
@@ -1749,7 +1812,7 @@ public class InstanceActor : ReceiveActor
|
||||
// WaitForAttribute (spec §4.2): re-evaluate any waiters on THIS attribute —
|
||||
// but ONLY when this publish reflects a real value change (evaluateWaiters).
|
||||
// The genuine value-change paths (HandleAttributeValueChanged, the scalar
|
||||
// DCL update path, HandleSetStaticAttributeCore) call it AFTER assigning
|
||||
// DCL update path, ApplySetStaticAttributeCore) call it AFTER assigning
|
||||
// _attributes[name], so changed.Value is the just-applied current value.
|
||||
// Republish/quality-only paths (List-coerce-failure Bad-quality, which
|
||||
// publishes the OLD value) pass evaluateWaiters:false so an "any-change" or
|
||||
|
||||
Reference in New Issue
Block a user