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
@@ -1268,18 +1268,14 @@ public class TemplateService
/// <param name="user">Username for the audit row.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <param name="loadedTemplates">
/// The full, TRACKED template graph if the caller already has one. Reconciling
/// writes through these entities and compares script bodies, so this must be
/// the tracked <c>GetAllTemplatesAsync</c> shape — never the no-tracking,
/// body-free analysis projection. When <see langword="null"/> the graph is
/// loaded here.
///
/// <para>
/// Passing it matters because every member Add/Update/Delete on
/// <see cref="TemplateService"/> used to load the whole graph once for its own
/// checks and then again inside this method. Handing the graph over collapses
/// that to one load per operation.
/// </para>
/// A TRACKED template graph the caller already has, containing at least the
/// template's derived subtree and inheritance chain. Reconciling writes through
/// these entities and compares script bodies, so this must be the tracked
/// <c>GetAllTemplatesAsync</c> shape — never the no-tracking, body-free analysis
/// projection. When <see langword="null"/> only the reconcile closure (the
/// subtree plus the template's chain, Gitea #86) is loaded here — never the
/// whole library, and nothing beyond one probe query when the template has no
/// children.
/// </param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task ReconcileDescendantsAsync(
@@ -1288,7 +1284,12 @@ public class TemplateService
CancellationToken cancellationToken = default,
IReadOnlyList<Template>? loadedTemplates = null)
{
var all = loadedTemplates ?? await _repository.GetAllTemplatesAsync(cancellationToken);
// The descendants' chains run through templateId and on up its own chain, and
// the resolver is inheritance-only, so the subtree + the template's chain is
// everything the reconcile reads (#86).
var all = loadedTemplates
?? await _repository.GetTemplateReconcileClosureAsync(templateId, cancellationToken)
?? Array.Empty<Template>();
var descendants = GetDescendantIdsBreadthFirst(templateId, all);
if (descendants.Count == 0)
return;