perf(templates): member add/update loads only the chain + composition closure (ScadaBridge#66)
Attribute/alarm/script add (collision check) and update (parent-lock lookup) used GetAllTemplatesForAnalysisAsync, materialising every template, attribute, alarm and script in the library per edit. They now call the new ITemplateEngineRepository.GetTemplateAnalysisClosureAsync: the same untracked, body-free analysis projection, restricted to the template's inheritance chain plus the composition closure of every chain member - exactly what TemplateResolver and CollisionDetector walk (a composed module's own parent chain is never climbed, so it is not loaded). Adds use the edited template's closure, updates the parent's. The walk (Commons TemplateAnalysisClosure) loads one batch per closure depth, requests each id once, terminates on cycles and skips dangling edges. Evidence: - TemplateAnalysisClosureTests: for every template of a graph with chains, nested + diamond compositions, a derived module, cycles, dangling edges and colliding unrelated templates, ResolveAllMembers / DetectCollisions / FindMemberByCanonicalName answer identically from the closure and the whole graph (mutation-checked). - TemplateAnalysisClosureRepositoryTests (SQLite, recorded SQL): 6 templates / 12 attributes loaded instead of 47 / 94, in 4 queries (one per depth), untracked, script bodies never selected. - TemplateServiceTests: two new tests with the whole-graph load wired to throw (fail on the old service); every existing add/update test now runs against the closure via AnalysisClosureMock. ReconcileDescendantsAsync still loads the tracked full graph; not in scope. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -13,6 +13,7 @@ public class TemplateServiceTests
|
||||
private readonly Mock<ITemplateEngineRepository> _repoMock;
|
||||
private readonly Mock<IAuditService> _auditMock;
|
||||
private readonly TemplateService _service;
|
||||
private readonly List<int> _closureRequests = new();
|
||||
|
||||
/// <summary>Initializes the test with mocked repositories and the service under test.</summary>
|
||||
public TemplateServiceTests()
|
||||
@@ -20,6 +21,7 @@ public class TemplateServiceTests
|
||||
_repoMock = new Mock<ITemplateEngineRepository>();
|
||||
_auditMock = new Mock<IAuditService>();
|
||||
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
|
||||
_repoMock.ServeClosureFromFullGraph(_closureRequests);
|
||||
_service = new TemplateService(_repoMock.Object, _auditMock.Object);
|
||||
}
|
||||
|
||||
@@ -2319,4 +2321,88 @@ public class TemplateServiceTests
|
||||
Assert.True(propagated.IsInherited);
|
||||
Assert.True(propagated.IsMesReportable);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Gitea #66: member add/update loads only the analysis closure
|
||||
// ========================================================================
|
||||
|
||||
private void WholeAnalysisGraphMustNotLoad() =>
|
||||
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("member add/update loaded the whole analysis graph"));
|
||||
|
||||
/// <summary>
|
||||
/// Member adds validate collisions against the template's own closure (here: itself plus
|
||||
/// its composed module) and never touch the whole-library analysis graph.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MemberAdds_CheckCollisionsAgainstClosure_NeverLoadWholeGraph()
|
||||
{
|
||||
var module = new Template("Motor") { Id = 2 };
|
||||
module.Attributes.Add(new TemplateAttribute("Temp") { Id = 20, TemplateId = 2 });
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
template.Compositions.Add(new TemplateComposition("m") { Id = 5, TemplateId = 1, ComposedTemplateId = 2 });
|
||||
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { template, module });
|
||||
_repoMock.Setup(r => r.GetTemplateAnalysisClosureAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { template, module });
|
||||
WholeAnalysisGraphMustNotLoad();
|
||||
|
||||
// A canonical-name collision with the composed module is still caught from the closure.
|
||||
var colliding = await _service.AddAttributeAsync(
|
||||
1, new TemplateAttribute("m.Temp") { DataType = DataType.Float, Value = "0" }, "admin");
|
||||
Assert.True(colliding.IsFailure);
|
||||
Assert.Contains("Naming collision on 'm.Temp'", colliding.Error);
|
||||
|
||||
Assert.True((await _service.AddAttributeAsync(
|
||||
1, new TemplateAttribute("Speed") { DataType = DataType.Float, Value = "0" }, "admin")).IsSuccess);
|
||||
Assert.True((await _service.AddAlarmAsync(
|
||||
1, new TemplateAlarm("Hi") { PriorityLevel = 500, TriggerType = AlarmTriggerType.ValueMatch }, "admin")).IsSuccess);
|
||||
Assert.True((await _service.AddScriptAsync(
|
||||
1, new TemplateScript("OnStart", "return true;") { TriggerType = "Startup" }, "admin")).IsSuccess);
|
||||
|
||||
_repoMock.Verify(r => r.GetTemplateAnalysisClosureAsync(1, It.IsAny<CancellationToken>()), Times.Exactly(4));
|
||||
_repoMock.Verify(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Member updates resolve the parent lock from the PARENT's closure: a member locked on the
|
||||
/// grandparent is found through the chain without loading the whole graph.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MemberUpdates_ResolveParentLockFromParentClosure_NeverLoadWholeGraph()
|
||||
{
|
||||
var grand = new Template("Grand") { Id = 1 };
|
||||
grand.Attributes.Add(new TemplateAttribute("Speed") { Id = 10, TemplateId = 1, DataType = DataType.Float, IsLocked = true });
|
||||
grand.Alarms.Add(new TemplateAlarm("Hi") { Id = 11, TemplateId = 1, IsLocked = true, TriggerType = AlarmTriggerType.ValueMatch });
|
||||
grand.Scripts.Add(new TemplateScript("Calc", string.Empty) { Id = 12, TemplateId = 1, IsLocked = true });
|
||||
var parent = new Template("Parent") { Id = 2, ParentTemplateId = 1 };
|
||||
var child = new Template("Child") { Id = 3, ParentTemplateId = 2 };
|
||||
var childAttr = new TemplateAttribute("Speed") { Id = 30, TemplateId = 3, DataType = DataType.Float };
|
||||
var childAlarm = new TemplateAlarm("Hi") { Id = 31, TemplateId = 3, TriggerType = AlarmTriggerType.ValueMatch };
|
||||
var childScript = new TemplateScript("Calc", "return 1;") { Id = 32, TemplateId = 3 };
|
||||
|
||||
_repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(30, It.IsAny<CancellationToken>())).ReturnsAsync(childAttr);
|
||||
_repoMock.Setup(r => r.GetTemplateAlarmByIdAsync(31, It.IsAny<CancellationToken>())).ReturnsAsync(childAlarm);
|
||||
_repoMock.Setup(r => r.GetTemplateScriptByIdAsync(32, It.IsAny<CancellationToken>())).ReturnsAsync(childScript);
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(3, It.IsAny<CancellationToken>())).ReturnsAsync(child);
|
||||
_repoMock.Setup(r => r.GetTemplateAnalysisClosureAsync(2, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { parent, grand });
|
||||
WholeAnalysisGraphMustNotLoad();
|
||||
|
||||
var attr = await _service.UpdateAttributeAsync(
|
||||
30, new TemplateAttribute("Speed") { DataType = DataType.Float, Value = "1" }, "admin");
|
||||
var alarm = await _service.UpdateAlarmAsync(
|
||||
31, new TemplateAlarm("Hi") { TriggerType = AlarmTriggerType.ValueMatch }, "admin");
|
||||
var script = await _service.UpdateScriptAsync(32, new TemplateScript("Calc", "return 2;"), "admin");
|
||||
|
||||
Assert.Contains("locked in parent", attr.Error);
|
||||
Assert.Contains("locked in parent", alarm.Error);
|
||||
Assert.Contains("locked in parent", script.Error);
|
||||
_repoMock.Verify(r => r.GetTemplateAnalysisClosureAsync(2, It.IsAny<CancellationToken>()), Times.Exactly(3));
|
||||
_repoMock.Verify(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user