perf(deploy): flatten-session caching, bulk DeploySiteAsync, paged management queries

This commit is contained in:
Joseph Doherty
2026-08-14 21:14:22 -04:00
parent ee193cd2bb
commit 48b3c40a7f
59 changed files with 3051 additions and 231 deletions
@@ -18,7 +18,10 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
@@ -242,13 +245,14 @@ public class ManagementActorTests : TestKit, IDisposable
[Fact]
public void ListTemplatesCommand_ReturnsTemplateData()
{
var templates = new List<Template>
{
new("PumpTemplate") { Id = 1, Description = "Pump" },
new("ValveTemplate") { Id = 2, Description = "Valve" }
};
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(templates);
// ListTemplates now reads DB-side row summaries rather than materialising
// every template's full child graph and paging it in memory.
_templateRepo.GetTemplateSummariesAsync(Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(new List<TemplateSummary>
{
new(1, "PumpTemplate", "Pump", null, null, false, null, 0, 0, 0, 0, 0),
new(2, "ValveTemplate", "Valve", null, null, false, null, 0, 0, 0, 0, 0)
});
var actor = CreateActor();
var envelope = Envelope(new ListTemplatesCommand());
@@ -343,7 +347,7 @@ public class ManagementActorTests : TestKit, IDisposable
[Fact]
public void ListTemplatesCommand_WhenRepoThrows_ReturnsManagementError()
{
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
_templateRepo.GetTemplateSummariesAsync(Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("Database connection lost"));
var actor = CreateActor();
@@ -1138,6 +1142,74 @@ public class ManagementActorTests : TestKit, IDisposable
private static Commons.Entities.Deployment.DeploymentRecord DeploymentRecordFor(int instanceId) =>
new("deploy-" + instanceId, "operator") { Id = instanceId, InstanceId = instanceId };
/// <summary>
/// Row-summary equivalent of <see cref="DeploymentRecordFor"/>. QueryDeployments
/// now returns DB-side projections instead of whole tracked entities.
/// </summary>
private static DeploymentRecordSummary DeploymentSummaryFor(int instanceId) =>
new(instanceId, "deploy-" + instanceId, instanceId, DeploymentStatus.Success,
null, "operator", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null);
/// <summary>
/// Stubs <c>QueryDeploymentSummariesAsync</c> so it applies the same
/// instance / status / scope / paging predicates the database would.
///
/// <para>
/// The filtering moved OUT of the handler and INTO the query, so a stub that
/// ignored the arguments would make these tests pass no matter what the handler
/// passed down. Honouring them here is what keeps the scope-enforcement
/// assertions meaningful.
/// </para>
/// </summary>
private static void StubDeploymentSummaryQuery(
IDeploymentManagerRepository repo,
IEnumerable<int> instanceIds)
{
var rows = instanceIds.Select(DeploymentSummaryFor).ToList();
repo.QueryDeploymentSummariesAsync(
Arg.Any<int?>(), Arg.Any<DeploymentStatus?>(), Arg.Any<IReadOnlyCollection<int>?>(),
Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{
var instanceId = ci.ArgAt<int?>(0);
var scope = ci.ArgAt<IReadOnlyCollection<int>?>(2);
var skip = ci.ArgAt<int>(3);
var take = ci.ArgAt<int?>(4);
IEnumerable<DeploymentRecordSummary> q = rows;
if (instanceId.HasValue)
q = q.Where(r => r.InstanceId == instanceId.Value);
if (scope != null)
q = q.Where(r => scope.Contains(r.InstanceId));
q = q.Skip(Math.Max(0, skip));
if (take is > 0)
q = q.Take(take.Value);
return (IReadOnlyList<DeploymentRecordSummary>)q.ToList();
});
}
/// <summary>
/// Stubs <c>GetTemplateSummariesAsync</c> over <paramref name="count"/> synthetic
/// templates, applying the requested Skip/Take exactly as the database would.
/// </summary>
private void StubTemplateSummaryPaging(int count)
{
var rows = Enumerable.Range(1, count)
.Select(i => new TemplateSummary(i, $"T-{i:D3}", null, null, null, false, null, 0, 0, 0, 0, 0))
.ToList();
_templateRepo.GetTemplateSummariesAsync(Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{
var skip = ci.ArgAt<int>(0);
var take = ci.ArgAt<int?>(1);
IEnumerable<TemplateSummary> q = rows.Skip(Math.Max(0, skip));
if (take is > 0)
q = q.Take(take.Value);
return (IReadOnlyList<TemplateSummary>)q.ToList();
});
}
[Fact]
public void QueryDeployments_WithDesignRole_ReturnsUnauthorized()
{
@@ -1154,11 +1226,7 @@ public class ManagementActorTests : TestKit, IDisposable
public void QueryDeployments_UnfilteredWithDeploymentRole_ReturnsAllRecords()
{
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
deployRepo.GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Deployment.DeploymentRecord>
{
DeploymentRecordFor(1), DeploymentRecordFor(2)
});
StubDeploymentSummaryQuery(deployRepo, [1, 2]);
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
@@ -1175,8 +1243,7 @@ public class ManagementActorTests : TestKit, IDisposable
public void QueryDeployments_FilteredByInstanceId_ReturnsInstanceRecords()
{
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
deployRepo.GetDeploymentsByInstanceIdAsync(5, Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Deployment.DeploymentRecord> { DeploymentRecordFor(5) });
StubDeploymentSummaryQuery(deployRepo, [5]);
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
@@ -1205,7 +1272,8 @@ public class ManagementActorTests : TestKit, IDisposable
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
// The out-of-scope instance's deployment history must not be queried.
deployRepo.DidNotReceiveWithAnyArgs().GetDeploymentsByInstanceIdAsync(default);
deployRepo.DidNotReceiveWithAnyArgs().QueryDeploymentSummariesAsync(
default, default, default, default, default);
}
[Fact]
@@ -1214,8 +1282,7 @@ public class ManagementActorTests : TestKit, IDisposable
_templateRepo.GetInstanceByIdAsync(5, Arg.Any<CancellationToken>())
.Returns(new Instance("Pump5") { Id = 5, SiteId = 1 });
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
deployRepo.GetDeploymentsByInstanceIdAsync(5, Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Deployment.DeploymentRecord> { DeploymentRecordFor(5) });
StubDeploymentSummaryQuery(deployRepo, [5]);
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
@@ -1240,11 +1307,7 @@ public class ManagementActorTests : TestKit, IDisposable
new("Pump2") { Id = 2, SiteId = 2 },
});
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
deployRepo.GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Deployment.DeploymentRecord>
{
DeploymentRecordFor(1), DeploymentRecordFor(2)
});
StubDeploymentSummaryQuery(deployRepo, [1, 2]);
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
@@ -1276,12 +1339,7 @@ public class ManagementActorTests : TestKit, IDisposable
new("Pump3") { Id = 3, SiteId = 1 },
});
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
deployRepo.GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Deployment.DeploymentRecord>
{
DeploymentRecordFor(1), DeploymentRecordFor(2), DeploymentRecordFor(3),
DeploymentRecordFor(1), DeploymentRecordFor(3) // duplicates: still no extra lookups
});
StubDeploymentSummaryQuery(deployRepo, [1, 2, 3]);
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
@@ -1300,11 +1358,7 @@ public class ManagementActorTests : TestKit, IDisposable
// Admin role bypasses site scoping even with PermittedSiteIds set.
// (The user also holds Deployment so it passes the role gate.)
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
deployRepo.GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Deployment.DeploymentRecord>
{
DeploymentRecordFor(1), DeploymentRecordFor(2)
});
StubDeploymentSummaryQuery(deployRepo, [1, 2]);
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
@@ -1391,10 +1445,11 @@ public class ManagementActorTests : TestKit, IDisposable
[Fact]
public void ListTemplates_SkipTake_ReturnsRequestedWindow()
{
var templates = Enumerable.Range(1, 30)
.Select(i => new Template($"T-{i:D3}") { Id = i })
.ToList();
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>()).Returns(templates);
// Paging moved into the repository (DB-side Skip/Take). The stub applies
// the same window the database would, so this still pins that the
// command's Skip/Take actually reach the query — the bug being that the
// handler used to load everything and slice in memory.
StubTemplateSummaryPaging(30);
var actor = CreateActor();
actor.Tell(Envelope(new ListTemplatesCommand(Skip: 10, Take: 5)));
@@ -1410,10 +1465,7 @@ public class ManagementActorTests : TestKit, IDisposable
[Fact]
public void ListTemplates_DefaultTake_ReturnsAll()
{
var templates = Enumerable.Range(1, 30)
.Select(i => new Template($"T-{i:D3}") { Id = i })
.ToList();
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>()).Returns(templates);
StubTemplateSummaryPaging(30);
var actor = CreateActor();
actor.Tell(Envelope(new ListTemplatesCommand())); // Take = null => unlimited
@@ -1527,7 +1579,7 @@ public class ManagementActorTests : TestKit, IDisposable
// Repository throws an unanticipated fault carrying sensitive-looking
// detail. The raw text must NOT reach the caller.
const string secret = "Server=db-internal-prod;constraint FK_secret";
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
_templateRepo.GetTemplateSummariesAsync(Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidProgramException(secret));
var actor = CreateActor();