c29b99c63b
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
126 lines
5.5 KiB
C#
126 lines
5.5 KiB
C#
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));
|
|
}
|
|
}
|