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
@@ -56,6 +56,19 @@ public interface ITemplateEngineRepository
|
||||
/// <returns>A task that resolves to a read-only list of all templates without script bodies.</returns>
|
||||
Task<IReadOnlyList<Template>> GetAllTemplatesForAnalysisAsync(CancellationToken cancellationToken = default);
|
||||
/// <summary>
|
||||
/// Returns the same untracked, script-body-free projection as
|
||||
/// <see cref="GetAllTemplatesForAnalysisAsync"/>, but only for the templates an analysis walk
|
||||
/// rooted at <paramref name="templateId"/> can reach: its inheritance chain plus the
|
||||
/// composition closure of every chain member (see
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Templates.TemplateAnalysisClosure"/>).
|
||||
/// Backs member add/update validation (Gitea #66), which previously loaded every template.
|
||||
/// Unknown ids yield an empty list.
|
||||
/// </summary>
|
||||
/// <param name="templateId">The template whose chain and composition closure to load.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that resolves to the closure templates without script bodies.</returns>
|
||||
Task<IReadOnlyList<Template>> GetTemplateAnalysisClosureAsync(int templateId, CancellationToken cancellationToken = default);
|
||||
/// <summary>
|
||||
/// Returns an untracked identity/edge projection of every template (no children), ordered by name.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||
|
||||
/// <summary>
|
||||
/// Computes the set of templates a member add/update analysis walk can reach from one template
|
||||
/// (Gitea #66): the template's <b>inheritance chain</b> (itself, its parent, grandparent, …) plus
|
||||
/// the <b>composition closure</b> of every chain member (each composed module, the modules they
|
||||
/// compose, and so on).
|
||||
///
|
||||
/// <para>
|
||||
/// This mirrors exactly what the analysis walkers read. <c>TemplateResolver.ResolveAllMembers</c>
|
||||
/// and <c>CollisionDetector.DetectCollisions</c> climb the parent chain of the template being
|
||||
/// analysed and descend composition edges from every chain member, but never climb a composed
|
||||
/// module's own parent chain (a derived module carries its inherited members as placeholder rows).
|
||||
/// So a composed module's ancestors are not loaded — only chain members expand their parent edge.
|
||||
/// Every other template in the library is irrelevant to the result, which is why member edits no
|
||||
/// longer need the whole analysis graph.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The walk is breadth-first by batch: each round asks <c>loadBatch</c> for every id not yet
|
||||
/// loaded, so a store-backed loader issues one query per closure depth rather than one per
|
||||
/// template. Missing ids (a dangling edge) are simply absent from the result, exactly as the
|
||||
/// walkers skip them. Cycles terminate: an id is expanded at most twice (once as a module, once
|
||||
/// more if it is later reached as a chain member).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class TemplateAnalysisClosure
|
||||
{
|
||||
/// <summary>
|
||||
/// Collects the inheritance chain and composition closure of <paramref name="templateId"/>.
|
||||
/// </summary>
|
||||
/// <param name="templateId">The template whose analysis closure is wanted.</param>
|
||||
/// <param name="loadBatch">Loads the templates with the given ids (unknown ids are omitted).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The closure templates, each once, in load order (the root first when it exists).</returns>
|
||||
public static async Task<IReadOnlyList<Template>> CollectAsync(
|
||||
int templateId,
|
||||
Func<IReadOnlyCollection<int>, CancellationToken, Task<IReadOnlyList<Template>>> loadBatch,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(loadBatch);
|
||||
|
||||
var loaded = new Dictionary<int, Template>();
|
||||
var result = new List<Template>();
|
||||
var requested = new HashSet<int>();
|
||||
// id -> true when expanded as a chain member (parent + compositions),
|
||||
// false when expanded as a module only (compositions).
|
||||
var expanded = new Dictionary<int, bool>();
|
||||
// Ids reached but not loaded yet, with "reached as chain member" OR'd in.
|
||||
var wanted = new Dictionary<int, bool> { [templateId] = true };
|
||||
|
||||
while (wanted.Count > 0)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var toLoad = wanted.Keys.Where(requested.Add).ToArray();
|
||||
if (toLoad.Length > 0)
|
||||
{
|
||||
var batch = await loadBatch(toLoad, cancellationToken).ConfigureAwait(false);
|
||||
foreach (var t in batch)
|
||||
{
|
||||
if (loaded.TryAdd(t.Id, t))
|
||||
result.Add(t);
|
||||
}
|
||||
}
|
||||
|
||||
var round = wanted.ToArray();
|
||||
wanted.Clear();
|
||||
foreach (var (id, asChain) in round)
|
||||
Expand(id, asChain, loaded, expanded, wanted, requested);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void Expand(
|
||||
int rootId,
|
||||
bool rootAsChain,
|
||||
Dictionary<int, Template> loaded,
|
||||
Dictionary<int, bool> expanded,
|
||||
Dictionary<int, bool> wanted,
|
||||
HashSet<int> requested)
|
||||
{
|
||||
var stack = new Stack<(int Id, bool AsChain)>();
|
||||
stack.Push((rootId, rootAsChain));
|
||||
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
var (id, asChain) = stack.Pop();
|
||||
|
||||
if (!loaded.TryGetValue(id, out var template))
|
||||
{
|
||||
// Already asked the loader and it does not exist: a dangling edge, skip it.
|
||||
if (requested.Contains(id))
|
||||
continue;
|
||||
wanted[id] = asChain || (wanted.TryGetValue(id, out var w) && w);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (expanded.TryGetValue(id, out var wasChain) && (wasChain || !asChain))
|
||||
continue;
|
||||
expanded[id] = asChain;
|
||||
|
||||
if (asChain && template.ParentTemplateId is int parentId)
|
||||
stack.Push((parentId, true));
|
||||
|
||||
foreach (var composition in template.Compositions)
|
||||
stack.Push((composition.ComposedTemplateId, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
-4
@@ -122,8 +122,33 @@ public class TemplateEngineRepository : ITemplateEngineRepository
|
||||
// <see cref="GetAllTemplatesAsync"/> / <see cref="GetTemplateWithChildrenAsync"/>.
|
||||
public async Task<IReadOnlyList<Template>> GetAllTemplatesForAnalysisAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Templates
|
||||
.AsNoTracking()
|
||||
return await AnalysisProjection(_context.Templates.AsNoTracking())
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
// One projection query per closure depth (TemplateAnalysisClosure batches each frontier),
|
||||
// each restricted to the frontier's ids — so a member edit reads its own chain and modules
|
||||
// instead of every template, attribute, alarm and script in the library.
|
||||
public Task<IReadOnlyList<Template>> GetTemplateAnalysisClosureAsync(int templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return TemplateAnalysisClosure.CollectAsync(
|
||||
templateId,
|
||||
async (ids, ct) =>
|
||||
{
|
||||
var idList = ids.ToList();
|
||||
return await AnalysisProjection(_context.Templates.AsNoTracking().Where(t => idList.Contains(t.Id)))
|
||||
.ToListAsync(ct);
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
// The analysis projection shared by GetAllTemplatesForAnalysisAsync and
|
||||
// GetTemplateAnalysisClosureAsync (see the note on the former for why it is untracked and
|
||||
// drops TemplateScript.Code).
|
||||
private static IQueryable<Template> AnalysisProjection(IQueryable<Template> source)
|
||||
{
|
||||
return source
|
||||
.Select(t => new Template(t.Name)
|
||||
{
|
||||
Id = t.Id,
|
||||
@@ -152,8 +177,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
|
||||
LockedInDerived = s.LockedInDerived
|
||||
})
|
||||
.ToList()
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -311,7 +311,8 @@ public class TemplateService
|
||||
attribute.TemplateId = templateId;
|
||||
|
||||
// If inheriting, validate not trying to add a member that would collide
|
||||
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
|
||||
// Only the template's inheritance chain + composition closure can collide (#66).
|
||||
var allTemplates = await _repository.GetTemplateAnalysisClosureAsync(templateId, cancellationToken);
|
||||
var testTemplate = CloneTemplateWithNewAttribute(template, attribute);
|
||||
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
|
||||
if (collisions.Count > 0)
|
||||
@@ -363,7 +364,8 @@ public class TemplateService
|
||||
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
|
||||
if (template?.ParentTemplateId != null)
|
||||
{
|
||||
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
|
||||
// The parent's members resolve from its chain + composition closure alone (#66).
|
||||
var allTemplates = await _repository.GetTemplateAnalysisClosureAsync(template.ParentTemplateId.Value, cancellationToken);
|
||||
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
||||
existing.Name, template.ParentTemplateId.Value, allTemplates);
|
||||
if (parentMember != null && parentMember.IsLocked)
|
||||
@@ -548,7 +550,8 @@ public class TemplateService
|
||||
alarm.TemplateId = templateId;
|
||||
|
||||
// Check collisions
|
||||
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
|
||||
// Only the template's inheritance chain + composition closure can collide (#66).
|
||||
var allTemplates = await _repository.GetTemplateAnalysisClosureAsync(templateId, cancellationToken);
|
||||
var testTemplate = CloneTemplateWithNewAlarm(template, alarm);
|
||||
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
|
||||
if (collisions.Count > 0)
|
||||
@@ -598,7 +601,8 @@ public class TemplateService
|
||||
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
|
||||
if (template?.ParentTemplateId != null)
|
||||
{
|
||||
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
|
||||
// The parent's members resolve from its chain + composition closure alone (#66).
|
||||
var allTemplates = await _repository.GetTemplateAnalysisClosureAsync(template.ParentTemplateId.Value, cancellationToken);
|
||||
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
||||
existing.Name, template.ParentTemplateId.Value, allTemplates);
|
||||
if (parentMember != null && parentMember.IsLocked)
|
||||
@@ -723,7 +727,8 @@ public class TemplateService
|
||||
script.TemplateId = templateId;
|
||||
|
||||
// Check collisions
|
||||
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
|
||||
// Only the template's inheritance chain + composition closure can collide (#66).
|
||||
var allTemplates = await _repository.GetTemplateAnalysisClosureAsync(templateId, cancellationToken);
|
||||
var testTemplate = CloneTemplateWithNewScript(template, script);
|
||||
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
|
||||
if (collisions.Count > 0)
|
||||
@@ -770,7 +775,8 @@ public class TemplateService
|
||||
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
|
||||
if (template?.ParentTemplateId != null)
|
||||
{
|
||||
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
|
||||
// The parent's members resolve from its chain + composition closure alone (#66).
|
||||
var allTemplates = await _repository.GetTemplateAnalysisClosureAsync(template.ParentTemplateId.Value, cancellationToken);
|
||||
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
||||
existing.Name, template.ParentTemplateId.Value, allTemplates);
|
||||
if (parentMember != null && parentMember.IsLocked)
|
||||
|
||||
Reference in New Issue
Block a user