perf(templates): descendant reconcile loads only the subtree + chain (ScadaBridge#86)
TemplateService.ReconcileDescendantsAsync - run after every base member add/update/delete, template and native-alarm-source alike - loaded the whole tracked template graph (every template, member and script body) on each call, which after #66 was the dominant cost of those paths. It now calls the new ITemplateEngineRepository.GetTemplateReconcileClosureAsync: the same tracked, fully-included shape as GetAllTemplatesAsync, restricted to the edited template's derived subtree plus its own inheritance chain. The reconcile is inheritance-only (TemplateInheritanceResolver never follows compositions) and every descendant's chain runs through the edited template, so that set is everything it reads. The walk (Commons TemplateReconcileClosure, the descendant-direction twin of #66's TemplateAnalysisClosure) loads one children batch per subtree depth and one batch per ancestor level, terminates on parent cycles and stops at dangling parents. A template with no children - the common case - costs one empty probe and loads nothing. Evidence: - TemplateReconcileClosureTests: for every template of a graph with a deep subtree, an ancestor above the edited template, a composed module, look-alike unrelated trees, a parent cycle and a dangling parent, the closure-backed reconcile issues exactly the writes (add/update/delete, audit, save) of the old whole-graph reconcile, and the resolver answers identically for every descendant. Fails with the old service (whole-graph load wired to throw) and with the ancestor walk removed (mutation-checked). - TemplateReconcileClosureRepositoryTests (SQLite, recorded SQL): 5 templates / 10 attributes instead of 47 / 94, tracked with script bodies; a leaf costs one probe query and tracks nothing. - TemplateServiceTests: attribute add + update propagate two levels down with GetAllTemplatesAsync wired to throw; existing propagation tests now run against the closure via ServeReconcileClosureFromFullGraph. ResyncInheritedMembersAsync (operator action) still reads the whole graph. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -5,6 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests;
|
||||
|
||||
@@ -22,6 +23,7 @@ public class TemplateServiceTests
|
||||
_auditMock = new Mock<IAuditService>();
|
||||
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
|
||||
_repoMock.ServeClosureFromFullGraph(_closureRequests);
|
||||
_repoMock.ServeReconcileClosureFromFullGraph();
|
||||
_service = new TemplateService(_repoMock.Object, _auditMock.Object);
|
||||
}
|
||||
|
||||
@@ -2405,4 +2407,66 @@ public class TemplateServiceTests
|
||||
_repoMock.Verify(r => r.GetTemplateAnalysisClosureAsync(2, It.IsAny<CancellationToken>()), Times.Exactly(3));
|
||||
_repoMock.Verify(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Gitea #86: descendant reconcile loads only the subtree + chain
|
||||
// ========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// A base member add/update/delete propagates to the derived subtree from the reconcile
|
||||
/// closure alone: the whole tracked graph is wired to throw and is never loaded, while an
|
||||
/// unrelated template sharing the member name is never touched.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MemberMutations_PropagateFromReconcileClosure_NeverLoadWholeTrackedGraph()
|
||||
{
|
||||
var baseT = BaseTemplate(7, "Base");
|
||||
baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.String, Value = "1" });
|
||||
var child = DerivedTemplate(8, "Child", 7);
|
||||
child.Attributes.Add(new TemplateAttribute("A") { Id = 80, TemplateId = 8, DataType = DataType.String, Value = "1", IsInherited = true });
|
||||
var grandChild = DerivedTemplate(9, "GrandChild", 8);
|
||||
var unrelated = BaseTemplate(50, "Unrelated");
|
||||
unrelated.Attributes.Add(new TemplateAttribute("A") { Id = 500, TemplateId = 50, DataType = DataType.String, Value = "x" });
|
||||
var library = new List<Template> { unrelated, baseT, child, grandChild };
|
||||
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(baseT);
|
||||
_repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(70, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(baseT.Attributes.Single());
|
||||
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>())).ReturnsAsync(library);
|
||||
_repoMock.Setup(r => r.GetTemplateReconcileClosureAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.Returns((int id, CancellationToken ct) => TemplateReconcileClosure.CollectAsync(
|
||||
id,
|
||||
(parents, _) => Task.FromResult<IReadOnlyList<Template>>(
|
||||
library.Where(t => t.ParentTemplateId is int p && parents.Contains(p)).ToList()),
|
||||
(ids, _) => Task.FromResult<IReadOnlyList<Template>>(library.Where(t => ids.Contains(t.Id)).ToList()),
|
||||
ct));
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("reconcile loaded the whole tracked graph"));
|
||||
|
||||
var added = new List<TemplateAttribute>();
|
||||
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<TemplateAttribute, CancellationToken>((a, _) =>
|
||||
{
|
||||
added.Add(a);
|
||||
if (a.TemplateId == 7) baseT.Attributes.Add(a); // simulate persistence of the base's own member
|
||||
})
|
||||
.Returns(Task.CompletedTask);
|
||||
var updated = new List<TemplateAttribute>();
|
||||
_repoMock.Setup(r => r.UpdateTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<TemplateAttribute, CancellationToken>((a, _) => updated.Add(a))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var add = await _service.AddAttributeAsync(7, new TemplateAttribute("C") { DataType = DataType.String, Value = "c" }, "admin");
|
||||
var update = await _service.UpdateAttributeAsync(70, new TemplateAttribute("A") { DataType = DataType.String, Value = "2" }, "admin");
|
||||
|
||||
Assert.True(add.IsSuccess, add.IsFailure ? add.Error : null);
|
||||
Assert.True(update.IsSuccess, update.IsFailure ? update.Error : null);
|
||||
Assert.Contains(added, a => a.TemplateId == 8 && a.Name == "C" && a.IsInherited);
|
||||
Assert.Contains(added, a => a.TemplateId == 9 && a.Name == "C" && a.IsInherited); // two levels down
|
||||
Assert.Contains(updated, a => a.TemplateId == 8 && a.Name == "A" && a.Value == "2");
|
||||
Assert.DoesNotContain(added.Concat(updated), a => a.TemplateId == 50);
|
||||
_repoMock.Verify(r => r.GetTemplateReconcileClosureAsync(7, It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
_repoMock.Verify(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user