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:
Joseph Doherty
2026-11-09 13:01:56 -05:00
parent 0eac956d25
commit c29b99c63b
10 changed files with 718 additions and 31 deletions
@@ -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);
}
}