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
@@ -0,0 +1,125 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
/// <summary>
/// Gitea #86: <see cref="TemplateEngineRepository.GetTemplateReconcileClosureAsync"/> reads only a
/// template's derived subtree + inheritance chain — tracked and fully included, the same shape as
/// <see cref="TemplateEngineRepository.GetAllTemplatesAsync"/> — instead of the whole library.
/// Asserted against real SQL on SQLite with the reader commands recorded.
/// </summary>
public class TemplateReconcileClosureRepositoryTests : IDisposable
{
private const int UnrelatedTemplates = 40;
private const string Body = "/* script body the reconcile compares and copies */";
private readonly SqlReaderCommandRecorder _recorder = new();
private readonly ScadaBridgeDbContext _context;
private readonly TemplateEngineRepository _repository;
/// <summary>Initializes the test class with an in-memory context and repository.</summary>
public TemplateReconcileClosureRepositoryTests()
{
_context = SqliteTestHelper.CreateInMemoryContext(_recorder);
_repository = new TemplateEngineRepository(_context, new TemplateGraphWatermark());
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
}
private async Task<Template> AddAsync(string name, int? parentId = null)
{
var t = new Template(name) { ParentTemplateId = parentId };
t.Attributes.Add(new TemplateAttribute($"{name}Attr1"));
t.Attributes.Add(new TemplateAttribute($"{name}Attr2"));
t.Scripts.Add(new TemplateScript($"{name}Script", Body));
_context.Templates.Add(t);
await _context.SaveChangesAsync();
return t;
}
private sealed record Seeded(int Root, int Base, int Mid, int Leaf, int Leaf3, int Sibling, int Module);
private async Task<Seeded> SeedAsync()
{
var root = await AddAsync("Root");
var @base = await AddAsync("Base", root.Id);
var mid = await AddAsync("Mid", @base.Id);
var leaf = await AddAsync("Leaf", mid.Id);
var leaf3 = await AddAsync("Leaf3", @base.Id);
var sibling = await AddAsync("Sibling", root.Id); // Base's sibling: not in Base's closure
var module = await AddAsync("Module");
_context.Set<TemplateComposition>().Add(
new TemplateComposition("m") { TemplateId = @base.Id, ComposedTemplateId = module.Id });
await _context.SaveChangesAsync();
for (var i = 0; i < UnrelatedTemplates; i++)
await AddAsync($"Unrelated{i}");
_context.ChangeTracker.Clear();
_recorder.Reset();
return new Seeded(root.Id, @base.Id, mid.Id, leaf.Id, leaf3.Id, sibling.Id, module.Id);
}
/// <summary>
/// The closure is exactly the subtree + chain — 5 templates instead of 47 — loaded tracked
/// with script bodies and every child collection, in one load per subtree depth + chain level.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Closure_ReadsOnlySubtreeAndChain_TrackedAndFullyIncluded()
{
var ids = await SeedAsync();
var whole = await _repository.GetAllTemplatesAsync();
var wholeQueries = _recorder.Commands.Count;
_context.ChangeTracker.Clear();
_recorder.Reset();
var closure = await _repository.GetTemplateReconcileClosureAsync(ids.Base);
var closureQueries = _recorder.Commands.Count;
Assert.Equal(
new[] { ids.Root, ids.Base, ids.Mid, ids.Leaf, ids.Leaf3 }.OrderBy(i => i),
closure.Select(t => t.Id).OrderBy(i => i));
Assert.Equal(7 + UnrelatedTemplates, whole.Count);
Assert.Equal(10, closure.Sum(t => t.Attributes.Count));
// Four full loads — children of {Base} and of {Mid, Leaf3}; chain Base, Root — each one
// split-query set like the whole-graph load, plus the empty children-of-{Leaf} probe whose
// includes never run. More round trips than the single whole-graph load, but each is
// bounded by the closure, not the library.
Assert.Equal(4 * wholeQueries + 1, closureQueries);
// Same shape the reconcile writes through: tracked, script bodies present, compositions loaded.
Assert.Equal(5, _context.ChangeTracker.Entries<Template>().Count());
Assert.All(closure.SelectMany(t => t.Scripts), s => Assert.Equal(Body, s.Code));
Assert.Single(closure.Single(t => t.Id == ids.Base).Compositions);
Assert.DoesNotContain(closure, t => t.Id == ids.Module || t.Id == ids.Sibling);
}
/// <summary>
/// The common case — a template nobody derives from — costs one children probe that returns
/// no rows, and loads and tracks nothing.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Closure_NoDescendants_SingleProbe_LoadsNothing()
{
var ids = await SeedAsync();
var closure = await _repository.GetTemplateReconcileClosureAsync(ids.Leaf);
Assert.Empty(closure);
Assert.Single(_recorder.Commands);
Assert.Empty(_context.ChangeTracker.Entries());
Assert.Empty(await _repository.GetTemplateReconcileClosureAsync(987654));
}
}