Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorAttributeBatchTests.cs
T
Joseph Doherty c782744b07 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.
2026-10-22 09:10:26 -04:00

132 lines
5.8 KiB
C#

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;
/// <summary>
/// Review 2026-10-21 01/SR-P4: <c>Route.To().GetAttributes()/SetAttributes()</c> 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.
/// </summary>
public class InstanceActorAttributeBatchTests : TestKit, IDisposable
{
private readonly SiteStorageService _storage;
private readonly ScriptCompilationService _compilationService;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly TestLocalDb _localDb;
/// <summary>Creates a temporary LocalDb-backed site store per test.</summary>
public InstanceActorAttributeBatchTests()
{
_localDb = TestLocalDb.CreateTemp("instance-attr-batch");
_storage = new SiteStorageService(_localDb.Db, NullLogger<SiteStorageService>.Instance);
_storage.InitializeAsync().GetAwaiter().GetResult();
_compilationService = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance);
_sharedScriptLibrary = new SharedScriptLibrary(
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
/// <inheritdoc />
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<InstanceActor>.Instance, dcl)));
}
private static SetStaticAttributeCommand Write(string attribute, string value) =>
new("corr", "Batch1", attribute, value, DateTimeOffset.UtcNow);
/// <summary>One reply, index-aligned, with found/quality per attribute.</summary>
[Fact]
public void GetAttributes_AnswersEveryNameInOneReply()
{
var instance = StartInstance();
instance.Tell(new InstanceGetAttributesRequest("corr", "Batch1", ["Count", "Missing", "Mode", "Speed"]));
var reply = ExpectMsg<InstanceGetAttributesResponse>(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));
}
/// <summary>Static writes apply; an unknown attribute fails on its own row only.</summary>
[Fact]
public void SetAttributes_StaticAndUnknown_ReportsPerAttributeOutcome()
{
var instance = StartInstance();
instance.Tell(new InstanceSetAttributesRequest("corr", [Write("Mode", "Auto"), Write("Nope", "1")]));
var reply = ExpectMsg<InstanceSetAttributesResponse>(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<GetAttributeResponse>().Value?.ToString());
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public void SetAttributes_DataSourcedWrite_WaitsForTheDclAndCarriesItsOutcome()
{
var dcl = CreateTestProbe();
var instance = StartInstance(dcl.Ref);
dcl.ExpectMsg<SubscribeTagsRequest>(TimeSpan.FromSeconds(10));
instance.Tell(new InstanceSetAttributesRequest("corr", [Write("Count", "9"), Write("Speed", "42")]));
var write = dcl.ExpectMsg<WriteTagRequest>(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<InstanceSetAttributesResponse>(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);
}
}