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
@@ -3108,8 +3108,9 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// <summary>
/// Reads attribute values from a deployed instance for a Route.To().GetAttribute(s)
/// call (or a central Test Run bound to the instance). Asks the Instance Actor
/// per attribute and combines the results.
/// call (or a central Test Run bound to the instance). One batched Ask to the Instance
/// Actor answers every attribute in a single pass (review 2026-10-21 01/SR-P4) — this used
/// to be one Ask (temp actor + mailbox round trip + 30 s timer) per attribute.
/// </summary>
private void RouteInboundApiGetAttributes(RouteToGetAttributesRequest request)
{
@@ -3124,17 +3125,14 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
var sender = Sender;
var names = request.AttributeNames;
var asks = names
.Select(name => instanceActor.Ask<GetAttributeResponse>(
new GetAttributeRequest(
request.CorrelationId, request.InstanceUniqueName, name, DateTimeOffset.UtcNow),
TimeSpan.FromSeconds(30)))
.ToArray();
Task.WhenAll(asks).ContinueWith(t =>
instanceActor.Ask<InstanceGetAttributesResponse>(
new InstanceGetAttributesRequest(request.CorrelationId, request.InstanceUniqueName, names),
TimeSpan.FromSeconds(30))
.ContinueWith(t =>
{
if (t.IsCompletedSuccessfully)
{
var results = t.Result.Results;
var values = new Dictionary<string, object?>();
for (var i = 0; i < names.Count; i++)
// Each attribute value crosses the Central↔Site PROCESS boundary inside
@@ -3146,8 +3144,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
// through the same normalizer so the wire carries a plain CLR graph
// (Dictionary/List/primitive). Keys are preserved; scalars/strings/null pass
// through unchanged.
values[names[i]] = t.Result[i].Found
? NormalizeRoutedReturnValue(t.Result[i].Value)
values[names[i]] = results[i].Found
? NormalizeRoutedReturnValue(results[i].Value)
: null;
return new RouteToGetAttributesResponse(
request.CorrelationId, values, true, null, DateTimeOffset.UtcNow);
@@ -3214,11 +3212,11 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// <summary>
/// Writes attribute values on a deployed instance for a Route.To().SetAttribute(s)
/// call (or a central Test Run bound to the instance). Each write is Ask'd to the
/// Instance Actor, which routes data-sourced attributes through the DCL and static
/// attributes to a persisted override. The response reflects the real per-attribute
/// outcome (a non-existent attribute or a failed device write reports failure),
/// rather than an unconditional optimistic ack.
/// call (or a central Test Run bound to the instance). The writes go to the Instance Actor
/// in ONE batched Ask (review 2026-10-21 01/SR-P4), which routes data-sourced attributes
/// through the DCL and static attributes to a persisted override. The response reflects the
/// real per-attribute outcome (a non-existent attribute or a failed device write reports
/// failure), rather than an unconditional optimistic ack.
/// </summary>
private void RouteInboundApiSetAttributes(RouteToSetAttributesRequest request)
{
@@ -3233,9 +3231,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
var sender = Sender;
var correlationId = request.CorrelationId;
var asks = request.AttributeValues
.Select(kvp => instanceActor.Ask<SetStaticAttributeResponse>(
new SetStaticAttributeCommand(
var writes = request.AttributeValues
.Select(kvp => new SetStaticAttributeCommand(
correlationId, request.InstanceUniqueName, kvp.Key, kvp.Value, DateTimeOffset.UtcNow,
// (ParentExecutionId tag-cascade): the routed
// write's originating execution is the inbound API request
@@ -3245,11 +3242,16 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
// script run under the inbound execution.
SourceExecutionId: request.ParentExecutionId,
// Flow Debugger (design §5.3): a traced routed write arms the echo stamp.
Trace: request.Trace),
TimeSpan.FromSeconds(30)))
.ToArray();
Trace: request.Trace))
.ToList();
Task.WhenAll(asks).ContinueWith(t =>
// Each data-sourced write inside the batch is bounded by its own 30 s DCL Ask, and the
// writes run concurrently; the slack keeps a device timeout surfacing as that attribute's
// own failure rather than as a whole-batch Ask timeout.
instanceActor.Ask<InstanceSetAttributesResponse>(
new InstanceSetAttributesRequest(correlationId, writes),
TimeSpan.FromSeconds(35))
.ContinueWith(t =>
{
if (!t.IsCompletedSuccessfully)
return new RouteToSetAttributesResponse(
@@ -3257,7 +3259,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
t.Exception?.GetBaseException().Message ?? "Attribute write timed out",
DateTimeOffset.UtcNow);
var failures = t.Result
var failures = t.Result.Results
.Where(r => !r.Success)
.Select(r => $"{r.AttributeName}: {r.ErrorMessage}")
.ToArray();