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:
Joseph Doherty
2026-10-22 06:37:06 -04:00
parent 35ffd025c3
commit c782744b07
5 changed files with 319 additions and 62 deletions
@@ -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