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
@@ -57,6 +57,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(parent);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { parent });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { parent });
var result = await _service.CreateTemplateAsync("Child", null, 1, "admin");
@@ -78,7 +80,11 @@ public class TemplateServiceTests
var result = await _service.CreateTemplateAsync("Child", null, 1, "admin");
Assert.True(result.IsSuccess);
// No whole-graph walk of ANY shape — neither the tracked
// GetAllTemplatesAsync nor the no-tracking analysis projection that the
// collision/acyclicity checks now use.
_repoMock.Verify(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()), Times.Never);
_repoMock.Verify(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
@@ -112,6 +118,8 @@ public class TemplateServiceTests
.ReturnsAsync(new List<Instance>());
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
var result = await _service.DeleteTemplateAsync(1, "admin");
@@ -128,6 +136,8 @@ public class TemplateServiceTests
.ReturnsAsync(new List<Instance> { new Instance("Pump1") { Id = 1, TemplateId = 1, SiteId = 1 } });
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
var result = await _service.DeleteTemplateAsync(1, "admin");
@@ -145,6 +155,8 @@ public class TemplateServiceTests
.ReturnsAsync(new List<Instance>());
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { parent, child });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { parent, child });
var result = await _service.DeleteTemplateAsync(1, "admin");
@@ -164,6 +176,8 @@ public class TemplateServiceTests
.ReturnsAsync(new List<Instance>());
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { moduleTemplate, composingTemplate });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { moduleTemplate, composingTemplate });
var result = await _service.DeleteTemplateAsync(1, "admin");
@@ -191,6 +205,13 @@ public class TemplateServiceTests
new Template("Child") { Id = 2, ParentTemplateId = 1 },
composer
});
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template>
{
template,
new Template("Child") { Id = 2, ParentTemplateId = 1 },
composer
});
var result = await _service.DeleteTemplateAsync(1, "admin");
@@ -211,6 +232,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
var attr = new TemplateAttribute("Temperature") { DataType = DataType.Float, Value = "0.0" };
var result = await _service.AddAttributeAsync(1, attr, "admin");
@@ -240,6 +263,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(8, It.IsAny<CancellationToken>())).ReturnsAsync(child);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { parent, child });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { parent, child });
var attr = new TemplateAttribute("MoveInType") { DataType = DataType.String, Value = "" };
var result = await _service.AddAttributeAsync(8, attr, "admin");
@@ -360,6 +385,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
var attr = new TemplateAttribute("Temperature") { DataType = DataType.Int32, Value = "not-a-number" };
var result = await _service.AddAttributeAsync(1, attr, "admin");
@@ -376,6 +403,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
var attr = new TemplateAttribute("SetPoints")
{
@@ -447,6 +476,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
var alarm = new TemplateAlarm("HighTemp")
{
@@ -510,6 +541,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
var script = new TemplateScript("OnStart", "return true;") { TriggerType = "Startup" };
var result = await _service.AddScriptAsync(1, script, "admin");
@@ -610,6 +643,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template, moduleTemplate });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template, moduleTemplate });
Template? captured = null;
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
@@ -651,6 +686,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(11, It.IsAny<CancellationToken>())).ReturnsAsync(sensorProbe1);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { pump, sensor, probe, sensorProbe1 });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { pump, sensor, probe, sensorProbe1 });
var captured = new List<Template>();
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
@@ -705,6 +742,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template, moduleTemplate, existing });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template, moduleTemplate, existing });
var captured = new List<Template>();
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
@@ -871,6 +910,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseTemplate, derived });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseTemplate, derived });
var proposed = new TemplateAttribute("SetPoint") { Value = "99", DataType = DataType.Float, IsInherited = false };
var result = await _service.UpdateAttributeAsync(100, proposed, "admin");
@@ -892,6 +933,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseTemplate, derived });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseTemplate, derived });
var proposed = new TemplateScript("Sample", "return 2;") { IsInherited = false };
var result = await _service.UpdateScriptAsync(200, proposed, "admin");
@@ -919,6 +962,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template, moduleTemplate });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template, moduleTemplate });
Template? captured = null;
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
@@ -965,6 +1010,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseTemplate, derived });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseTemplate, derived });
var proposed = new TemplateAlarm("HighTemp")
{
@@ -1003,6 +1050,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseTemplate, derived });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseTemplate, derived });
var proposed = new TemplateAlarm("HighTemp")
{
@@ -1030,6 +1079,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseTemplate, derived });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseTemplate, derived });
var proposed = new TemplateAttribute("SetPoint") { Value = "99", DataType = DataType.Float, IsInherited = false };
var result = await _service.UpdateAttributeAsync(100, proposed, "admin");
@@ -1052,6 +1103,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(5, It.IsAny<CancellationToken>())).ReturnsAsync(new List<Instance>());
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseTemplate, parent, derived });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseTemplate, parent, derived });
var result = await _service.DeleteTemplateAsync(5, "admin");
@@ -1083,6 +1136,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
var result = await _service.AddCompositionAsync(1, 1, "self", "admin");
@@ -1206,6 +1261,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(childTemplate);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { parentTemplate, childTemplate });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { parentTemplate, childTemplate });
var proposed = new TemplateAttribute("Speed")
{
@@ -1241,6 +1298,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(childTemplate);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { parentTemplate, childTemplate });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { parentTemplate, childTemplate });
var result = await _service.DeleteAttributeAsync(20, "admin");
@@ -1295,6 +1354,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(child);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { child });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { child });
var result = await _service.UpdateTemplateAsync(2, "ChildRenamed", null, null, "admin");
@@ -1313,6 +1374,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(t);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { t });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { t });
var result = await _service.UpdateTemplateAsync(1, "T", "", null, "admin");
@@ -1328,6 +1391,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(child);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { child });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { child });
// Idempotent pass — same parent value sent on update should succeed and apply name/description changes.
var result = await _service.UpdateTemplateAsync(2, "ChildRenamed", "new", 1, "admin");
@@ -1353,6 +1418,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(templateA);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { templateA, templateB, templateC });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { templateA, templateB, templateC });
var result = await _service.AddCompositionAsync(3, 1, "a1", "admin");
@@ -1373,6 +1440,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetFolderByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { t });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { t });
var result = await _service.MoveTemplateAsync(1, 7, "admin");
@@ -1387,6 +1456,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(t);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { t });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { t });
var result = await _service.MoveTemplateAsync(1, null, "admin");
@@ -1422,6 +1493,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetFolderByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { moving, existing });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { moving, existing });
var result = await _service.MoveTemplateAsync(1, 7, "admin");
@@ -1444,6 +1517,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetFolderByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { moving, unrelated });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { moving, unrelated });
var result = await _service.MoveTemplateAsync(1, 7, "admin");
@@ -1536,6 +1611,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
TemplateAttribute? added = null;
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
@@ -1576,6 +1653,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
TemplateAlarm? added = null;
_repoMock.Setup(r => r.AddTemplateAlarmAsync(It.IsAny<TemplateAlarm>(), It.IsAny<CancellationToken>()))
@@ -1621,6 +1700,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
TemplateScript? added = null;
_repoMock.Setup(r => r.AddTemplateScriptAsync(It.IsAny<TemplateScript>(), It.IsAny<CancellationToken>()))
@@ -1676,6 +1757,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
var added = new List<TemplateAttribute>();
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
.Callback<TemplateAttribute, CancellationToken>((a, _) => added.Add(a))
@@ -1707,6 +1790,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
var deleted = new List<int>();
_repoMock.Setup(r => r.DeleteTemplateAttributeAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.Callback<int, CancellationToken>((id, _) => deleted.Add(id))
@@ -1731,6 +1816,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
var updated = new List<TemplateAttribute>();
_repoMock.Setup(r => r.UpdateTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
.Callback<TemplateAttribute, CancellationToken>((a, _) => updated.Add(a))
@@ -1756,6 +1843,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
var result = await _service.ResyncInheritedMembersAsync(8, "admin");
@@ -1781,6 +1870,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, left, right });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, left, right });
var added = new List<TemplateAttribute>();
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
.Callback<TemplateAttribute, CancellationToken>((a, _) => added.Add(a))
@@ -1809,6 +1900,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
var addedScripts = new List<TemplateScript>();
var addedSources = new List<TemplateNativeAlarmSource>();
_repoMock.Setup(r => r.AddTemplateScriptAsync(It.IsAny<TemplateScript>(), It.IsAny<CancellationToken>()))
@@ -1846,6 +1939,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
var added = new List<TemplateAttribute>();
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
.Callback<TemplateAttribute, CancellationToken>((a, _) => added.Add(a)).Returns(Task.CompletedTask);
@@ -1871,6 +1966,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(baseT);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
var added = new List<TemplateAttribute>();
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
@@ -0,0 +1,135 @@
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Validation;
/// <summary>
/// WP2.5: the verdict cache's eviction policy. Overflow used to <c>Clear()</c> the
/// whole cache, which re-opened the non-collectible <c>InteractiveAssemblyLoader</c>
/// leak the cache exists to bound — every hot script had to be recompiled, and every
/// recompile loads another assembly that can never be unloaded. These tests pin the
/// replacement: eviction is segmented, so entries in active use survive it.
///
/// <para>
/// Serialised with the other verdict-cache tests: the cache is process-wide static
/// state, so two test classes filling it concurrently would see each other's
/// entries.
/// </para>
/// </summary>
[Collection("ScriptCompileVerdictCache")]
public class ScriptCompileVerdictCacheEvictionTests
{
private const string Surface = "TestSurface";
/// <summary>Entries needed to force at least one generation rotation.</summary>
private const int OverflowCount = 5000;
private static (bool Ok, string? Error) Lookup(string code, Func<(bool, string?)> factory) =>
ScriptCompileVerdictCache.GetOrAdd(Surface, code, factory);
[Fact]
public void Overflow_KeepsHotEntry_AndDoesNotClearEverything()
{
ScriptCompileVerdictCache.Clear();
const string hotCode = "// the script every deploy re-validates";
var hotCompiles = 0;
(bool, string?) HotFactory()
{
hotCompiles++;
return (true, null);
}
Lookup(hotCode, HotFactory);
Assert.Equal(1, hotCompiles);
// Push far more distinct scripts through than the cache can hold, touching
// the hot entry as we go — which is exactly what a real workload does, and
// exactly what wholesale Clear() used to throw away.
for (var i = 0; i < OverflowCount; i++)
{
Lookup($"// filler {i}", static () => (true, null));
if (i % 25 == 0)
Lookup(hotCode, HotFactory);
}
// At least one rotation happened...
Assert.True(ScriptCompileVerdictCache.Evictions > 0,
"the overflow did not trigger a single eviction — the test no longer exercises the policy");
// ...and the hot entry was never recompiled, because a hit in the cold
// generation promotes it back into hot rather than letting it age out.
Assert.Equal(1, hotCompiles);
// A final read still hits.
var compilesBefore = hotCompiles;
Lookup(hotCode, HotFactory);
Assert.Equal(compilesBefore, hotCompiles);
}
[Fact]
public void Overflow_RetainsRecentEntries_RatherThanDroppingAll()
{
ScriptCompileVerdictCache.Clear();
for (var i = 0; i < OverflowCount; i++)
Lookup($"// bulk {i}", static () => (true, null));
Assert.True(ScriptCompileVerdictCache.Evictions > 0);
// The most recently inserted entry is in the hot generation, so it must
// still be cached. Under the old Clear()-on-overflow policy the cache could
// be left holding a single entry after a rotation.
var recompiled = false;
Lookup($"// bulk {OverflowCount - 1}", () =>
{
recompiled = true;
return (true, null);
});
Assert.False(recompiled, "the most recent entry was evicted; eviction is not retaining the hot generation");
Assert.True(ScriptCompileVerdictCache.Count > 1,
$"cache retained only {ScriptCompileVerdictCache.Count} entries after eviction");
}
[Fact]
public void Overflow_KeepsCacheBounded()
{
ScriptCompileVerdictCache.Clear();
for (var i = 0; i < OverflowCount; i++)
Lookup($"// bounded {i}", static () => (true, null));
// Two generations of 2048 — the same 4096 ceiling the previous policy had,
// now reached by demotion rather than by dropping everything.
Assert.True(ScriptCompileVerdictCache.Count <= 4096,
$"cache grew to {ScriptCompileVerdictCache.Count} entries, exceeding its two-generation bound");
}
[Fact]
public void SurfaceIsPartOfTheKey_AcrossEviction()
{
ScriptCompileVerdictCache.Clear();
const string code = "// same body, two surfaces";
Lookup(code, static () => (true, null));
var otherSurfaceCompiled = false;
var verdict = ScriptCompileVerdictCache.GetOrAdd("OtherSurface", code, () =>
{
otherSurfaceCompiled = true;
return (false, "not valid against this surface");
});
// A verdict is never interchangeable across globals surfaces, and the
// segmented cache must not weaken that.
Assert.True(otherSurfaceCompiled);
Assert.False(verdict.Ok);
}
}
/// <summary>
/// Serialises every test that touches the process-wide
/// <see cref="ScriptCompileVerdictCache"/> static.
/// </summary>
[CollectionDefinition("ScriptCompileVerdictCache")]
public class ScriptCompileVerdictCacheCollection;
@@ -10,6 +10,10 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Validation;
/// authoritative behavior: bypasses the old substring scan missed are now caught,
/// and undefined symbols (which a structural scan could never see) fail compile.
/// </summary>
// Serialised with ScriptCompileVerdictCacheEvictionTests: both drive the
// process-wide ScriptCompileVerdictCache static, and an overflow test running
// concurrently would evict the entries these hit-count assertions depend on.
[Collection("ScriptCompileVerdictCache")]
public class ScriptCompilerTests
{
private readonly ScriptCompiler _sut = new();