From c782744b0776c94152007c3836d206db536e6479 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 06:37:06 -0400 Subject: [PATCH] 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 outcome shared by both paths; static writes and rejections still reply synchronously. --- docs/requirements/Component-SiteRuntime.md | 2 + .../Actors/DeploymentManagerActor.cs | 52 +++---- .../Actors/InstanceActor.cs | 137 +++++++++++++----- .../Messages/InstanceAttributeBatch.cs | 59 ++++++++ .../InstanceActorAttributeBatchTests.cs | 131 +++++++++++++++++ 5 files changed, 319 insertions(+), 62 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Messages/InstanceAttributeBatch.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorAttributeBatchTests.cs diff --git a/docs/requirements/Component-SiteRuntime.md b/docs/requirements/Component-SiteRuntime.md index 64a16abe..01c710d9 100644 --- a/docs/requirements/Component-SiteRuntime.md +++ b/docs/requirements/Component-SiteRuntime.md @@ -233,6 +233,8 @@ flowchart TD - **SetAttribute** (for attributes with data source reference): Sends a write request to the Data Connection Layer. The DCL writes to the physical device. If the write fails (connection down, device rejection, timeout), the error is returned synchronously to the calling script for handling. On success, the existing subscription picks up the confirmed value from the device and sends it back as a value update, which then updates the in-memory value. The in-memory value is **not** optimistically updated. - **SetAttribute** (for static attributes): Updates the in-memory value and **persists the override to local SQLite**. On restart (or failover), the Instance Actor loads persisted overrides on top of the deployed configuration, preserving runtime-modified values. A redeployment of the instance resets all persisted overrides to the new deployed configuration values. +> **Routed multi-attribute reads and writes are one message each (review 2026-10-21 01/SR-P4).** `Route.To().GetAttributes()` / `SetAttributes()` used to cost one Ask — a temp actor, a mailbox round trip and a 30 s timer — per attribute. The Deployment Manager now sends the Instance Actor a single site-local `InstanceGetAttributesRequest` / `InstanceSetAttributesRequest` (`SiteRuntime/Messages/InstanceAttributeBatch.cs`, never on the wire). Reads are answered in one pass from one consistent point in the mailbox. Writes are applied in order exactly as the equivalent single `SetStaticAttributeCommand`s (static → in-memory + coalesced persist, data-sourced → DCL write, unknown/poison values rejected); data-sourced writes are issued concurrently and the reply waits only for those, each still bounded by its own 30 s DCL Ask (the batch Ask allows 35 s so a device timeout surfaces as that attribute's own failure). Both replies carry the per-attribute `GetAttributeResponse` / `SetStaticAttributeResponse`, so the routed response is unchanged: a failed device write or unknown attribute is still reported by name. + > **Loaded overrides are republished to the children and the stream (review 2026-10-21 01/SR-U1).** The override load is asynchronous and lands *after* `PreStart` has already created the Script/Alarm Actors, whose trigger snapshots are seeded from the attribute set at that moment — the deploy **defaults**. Static attributes never re-emit a change on their own, so before this fix every trigger on an instance that restarted, failed over, was enabled or was started by the reconcile sweep evaluated an overridden attribute at its default indefinitely (an Expression alarm on `Mode == "Auto"` never raised), and the Debug View live stream showed the default, while `GetAttribute` answered correctly. The load now republishes each override that changed the in-memory value or quality as an ordinary `AttributeValueChanged` (quality from the load, no `SourceExecutionId`) through the same fan-out every change uses, with waiter evaluation **off** — nothing can legitimately be waiting on a replay. Consequence, accepted deliberately: a ValueChange/Conditional script keyed on an overridden static attribute fires once as the actor starts, exactly as one keyed on a data-sourced attribute already fires on that attribute's first DCL value. A static write that lands before the load is newer than the stored row, so the load skips that attribute rather than rolling it back. Overrides for attributes the deployed config no longer defines are still applied (unchanged) but not republished. > **Stopping an instance answers its pending waits (review 2026-10-21 01/SR-M3).** `PostStop` cancels every registered `WaitForAttribute` timeout and replies `Matched=false, TimedOut=false, ErrorMessage="… abandoned: instance stopped"`, so a script parked in `Attributes.WaitAsync` across a redeploy/disable/delete learns why immediately instead of burning its whole wait plus the Ask timeout and reporting a time-out, and no `WaitForAttributeTimeout` lands in dead letters (a monitored health metric). diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs index 89e7ecf3..ef16be99 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs @@ -3108,8 +3108,9 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers /// /// 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. /// 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( - new GetAttributeRequest( - request.CorrelationId, request.InstanceUniqueName, name, DateTimeOffset.UtcNow), - TimeSpan.FromSeconds(30))) - .ToArray(); - - Task.WhenAll(asks).ContinueWith(t => + instanceActor.Ask( + new InstanceGetAttributesRequest(request.CorrelationId, request.InstanceUniqueName, names), + TimeSpan.FromSeconds(30)) + .ContinueWith(t => { if (t.IsCompletedSuccessfully) { + var results = t.Result.Results; var values = new Dictionary(); 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 /// /// 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. /// 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( - 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( + 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(); diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs index f17c271f..67633e6c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs @@ -321,6 +321,11 @@ public class InstanceActor : ReceiveActor // Handle attribute queries (Tell pattern -- sender gets response) Receive(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(HandleGetAttributes); + Receive(HandleSetAttributes); + // Handle static attribute writes Receive(HandleSetStaticAttribute); @@ -509,18 +514,75 @@ public class InstanceActor : ReceiveActor /// /// Returns the current attribute value. Uses Tell pattern; sender gets the response. /// - private void HandleGetAttribute(GetAttributeRequest request) + private void HandleGetAttribute(GetAttributeRequest request) => + Sender.Tell(ReadAttribute(request.CorrelationId, request.AttributeName)); + + /// The single-attribute read shared by the single and batched paths. + 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); + } + + /// + /// Review 2026-10-21 01/SR-P4: answers every requested attribute in one pass, one + /// per name (index-aligned), from a single consistent + /// point in this actor's mailbox. + /// + 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)); + } + + /// + /// Review 2026-10-21 01/SR-P4: applies each write in order exactly as the equivalent single + /// 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. + /// + private void HandleSetAttributes(InstanceSetAttributesRequest request) + { + var caller = Sender; + var outcomes = new Task[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); + } + + /// + /// 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. + /// + private static void ReplyWhenDone(Task outcome, IActorRef caller) + { + if (outcome.IsCompleted) + caller.Tell(outcome.Result); + else + outcome.PipeTo(caller); } /// @@ -538,7 +600,15 @@ public class InstanceActor : ReceiveActor /// /// Either way the caller receives a . /// - private void HandleSetStaticAttribute(SetStaticAttributeCommand command) + private void HandleSetStaticAttribute(SetStaticAttributeCommand command) => + ReplyWhenDone(ApplySetAttribute(command), Sender); + + /// + /// Applies one attribute write (see ) 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). + /// + private Task 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)); } /// /// 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. /// - 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); } /// @@ -728,14 +793,14 @@ public class InstanceActor : ReceiveActor } /// - /// 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. /// - private void HandleSetDataAttribute(SetStaticAttributeCommand command, AttributeIndexEntry resolved) + private Task 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 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(writeRequest, TimeSpan.FromSeconds(30)) + return _dclManager.Ask(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); + }); } /// /// 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 ), + /// whole batch, decodes List values (same rule as ), /// and forwards ONE 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 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 diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Messages/InstanceAttributeBatch.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Messages/InstanceAttributeBatch.cs new file mode 100644 index 00000000..435cd793 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Messages/InstanceAttributeBatch.cs @@ -0,0 +1,59 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance; + +namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Messages; + +/// +/// Site-local batch read of several attributes from ONE Instance Actor, answered in a single +/// pass with (review 2026-10-21 01/SR-P4). Sent by +/// the Deployment Manager for Route.To().GetAttributes() instead of one Ask — temp actor, +/// mailbox round trip and 30 s timer — per attribute. +/// +/// +/// Never crosses a process boundary: the Deployment Manager and its Instance Actors share a +/// node, so this is a plain in-memory record with no wire-compatibility contract. (The unused +/// Commons GetAttributesBatchRequest was not reused because it has no per-attribute +/// found/quality outcome.) +/// +/// +/// Echoed on the response and on every per-attribute result. +/// The instance being read. +/// Attributes to read, in the order results are returned. +public sealed record InstanceGetAttributesRequest( + string CorrelationId, + string InstanceUniqueName, + IReadOnlyList AttributeNames); + +/// +/// Reply to : exactly one +/// per requested name, index-aligned with the request, each +/// carrying its own found/quality outcome — the same records a single read returns. +/// +/// The request's correlation id. +/// Per-attribute results, index-aligned with the request. +public sealed record InstanceGetAttributesResponse( + string CorrelationId, + IReadOnlyList Results); + +/// +/// Site-local batch write of several attributes on ONE Instance Actor (review 2026-10-21 +/// 01/SR-P4), backing Route.To().SetAttributes(). Each write is applied in order exactly +/// as the equivalent single would be (static → in-memory + +/// coalesced persist; data-sourced → DCL write), and the reply waits only for the data-sourced +/// writes still in flight. +/// +/// Echoed on the response. +/// The writes, in application order. +public sealed record InstanceSetAttributesRequest( + string CorrelationId, + IReadOnlyList Writes); + +/// +/// Reply to : one +/// per write, index-aligned, so a failed device write or an unknown attribute is reported per +/// attribute just as a single write reports it. +/// +/// The request's correlation id. +/// Per-write results, index-aligned with the request. +public sealed record InstanceSetAttributesResponse( + string CorrelationId, + IReadOnlyList Results); diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorAttributeBatchTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorAttributeBatchTests.cs new file mode 100644 index 00000000..8c28a385 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorAttributeBatchTests.cs @@ -0,0 +1,131 @@ +using System.Text.Json; +using Akka.Actor; +using Akka.TestKit.Xunit2; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Messages; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; +using ZB.MOM.WW.ScadaBridge.TestSupport; + +namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; + +/// +/// Review 2026-10-21 01/SR-P4: Route.To().GetAttributes()/SetAttributes() used one Ask +/// per attribute. The Instance Actor now answers a whole batch in one message while keeping the +/// per-attribute outcome a single read/write would report. +/// +public class InstanceActorAttributeBatchTests : TestKit, IDisposable +{ + private readonly SiteStorageService _storage; + private readonly ScriptCompilationService _compilationService; + private readonly SharedScriptLibrary _sharedScriptLibrary; + private readonly TestLocalDb _localDb; + + /// Creates a temporary LocalDb-backed site store per test. + public InstanceActorAttributeBatchTests() + { + _localDb = TestLocalDb.CreateTemp("instance-attr-batch"); + _storage = new SiteStorageService(_localDb.Db, NullLogger.Instance); + _storage.InitializeAsync().GetAwaiter().GetResult(); + _compilationService = new ScriptCompilationService(NullLogger.Instance); + _sharedScriptLibrary = new SharedScriptLibrary( + _compilationService, NullLogger.Instance); + } + + /// + void IDisposable.Dispose() + { + Shutdown(); + var path = _localDb.Path; + _localDb.Dispose(); + TestLocalDb.DeleteFiles(path); + } + + private IActorRef StartInstance(IActorRef? dcl = null) + { + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Batch1", + Attributes = + [ + new ResolvedAttribute { CanonicalName = "Mode", Value = "Manual", DataType = "String" }, + new ResolvedAttribute { CanonicalName = "Count", Value = "3", DataType = "Int32" }, + new ResolvedAttribute + { + CanonicalName = "Speed", Value = "0", DataType = "Int32", + DataSourceReference = "ns=2;s=Speed", BoundDataConnectionName = "conn-1" + } + ] + }; + var json = JsonSerializer.Serialize(config); + return ActorOf(Props.Create(() => new InstanceActor( + "Batch1", json, _storage, _compilationService, _sharedScriptLibrary, null, + new SiteRuntimeOptions(), NullLogger.Instance, dcl))); + } + + private static SetStaticAttributeCommand Write(string attribute, string value) => + new("corr", "Batch1", attribute, value, DateTimeOffset.UtcNow); + + /// One reply, index-aligned, with found/quality per attribute. + [Fact] + public void GetAttributes_AnswersEveryNameInOneReply() + { + var instance = StartInstance(); + + instance.Tell(new InstanceGetAttributesRequest("corr", "Batch1", ["Count", "Missing", "Mode", "Speed"])); + var reply = ExpectMsg(TimeSpan.FromSeconds(10)); + + Assert.Equal(["Count", "Missing", "Mode", "Speed"], reply.Results.Select(r => r.AttributeName)); + Assert.Equal("3", reply.Results[0].Value?.ToString()); + Assert.False(reply.Results[1].Found); + Assert.Equal("Manual", reply.Results[2].Value?.ToString()); + Assert.Equal("Uncertain", reply.Results[3].Quality); + ExpectNoMsg(TimeSpan.FromMilliseconds(200)); + } + + /// Static writes apply; an unknown attribute fails on its own row only. + [Fact] + public void SetAttributes_StaticAndUnknown_ReportsPerAttributeOutcome() + { + var instance = StartInstance(); + + instance.Tell(new InstanceSetAttributesRequest("corr", [Write("Mode", "Auto"), Write("Nope", "1")])); + var reply = ExpectMsg(TimeSpan.FromSeconds(10)); + + Assert.True(reply.Results[0].Success); + Assert.False(reply.Results[1].Success); + Assert.Contains("Unknown attribute 'Nope'", reply.Results[1].ErrorMessage); + + instance.Tell(new GetAttributeRequest("g", "Batch1", "Mode", DateTimeOffset.UtcNow)); + Assert.Equal("Auto", ExpectMsg().Value?.ToString()); + } + + /// + /// A data-sourced write inside the batch goes to the DCL; the batch reply waits for the + /// device answer and carries its failure on that attribute's row. + /// + [Fact] + public void SetAttributes_DataSourcedWrite_WaitsForTheDclAndCarriesItsOutcome() + { + var dcl = CreateTestProbe(); + var instance = StartInstance(dcl.Ref); + dcl.ExpectMsg(TimeSpan.FromSeconds(10)); + + instance.Tell(new InstanceSetAttributesRequest("corr", [Write("Count", "9"), Write("Speed", "42")])); + + var write = dcl.ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.Equal("ns=2;s=Speed", write.TagPath); + ExpectNoMsg(TimeSpan.FromMilliseconds(300)); // the reply waits for the device + dcl.Reply(new WriteTagResponse(write.CorrelationId, false, "device rejected", DateTimeOffset.UtcNow)); + + var reply = ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.True(reply.Results[0].Success); + Assert.False(reply.Results[1].Success); + Assert.Equal("Speed", reply.Results[1].AttributeName); + Assert.Equal("device rejected", reply.Results[1].ErrorMessage); + } +}