From c29b99c63b71afe4739ab9f1b2ef2ce8e434beef Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 9 Nov 2026 13:01:56 -0500 Subject: [PATCH] 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 --- docs/requirements/Component-TemplateEngine.md | 25 +- .../Repositories/ITemplateEngineRepository.cs | 14 + .../Templates/TemplateReconcileClosure.cs | 103 ++++++ .../Repositories/TemplateEngineRepository.cs | 47 ++- .../TemplateService.cs | 27 +- ...TemplateReconcileClosureRepositoryTests.cs | 125 +++++++ .../AnalysisClosureMock.cs | 29 ++ .../TemplateAuditShapeTests.cs | 1 + .../TemplateReconcileClosureTests.cs | 314 ++++++++++++++++++ .../TemplateServiceTests.cs | 64 ++++ 10 files changed, 718 insertions(+), 31 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Types/Templates/TemplateReconcileClosure.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/TemplateReconcileClosureRepositoryTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateReconcileClosureTests.cs diff --git a/docs/requirements/Component-TemplateEngine.md b/docs/requirements/Component-TemplateEngine.md index 7536cf76..ce087ac7 100644 --- a/docs/requirements/Component-TemplateEngine.md +++ b/docs/requirements/Component-TemplateEngine.md @@ -219,17 +219,22 @@ names, locks and edges: `InstanceService.SetAlarmOverrideAsync` (resolving one a name) and `TemplateDeletionService.CanDeleteTemplateAsync` (inheritor / derived / composer checks). `TemplateFolderService.DeleteFolderAsync`, which only counts templates per `FolderId`, reads the lighter `GetTemplateStubsAsync` identity projection -(review 2026-10-21 04/P6, 08/P1). Template member add/update paths still load the whole -analysis projection for collision detection; narrowing that to the edited template's -chain + composition closure needs a closure-scoped repository read that does not exist -yet. +(review 2026-10-21 04/P6, 08/P1). Template member add/update paths read only the +edited template's inheritance chain + composition closure through +`GetTemplateAnalysisClosureAsync` (the same projection, one query per closure depth — +Gitea #66). -The tracked, body-bearing `GetAllTemplatesAsync` remains the read for the two paths -that genuinely need it — the inheritance reconciler (which compares and copies -script bodies and writes through the loaded entities) and the flattener (which must -observe rows an in-flight bundle import has staged on the shared change tracker). -`ReconcileDescendantsAsync` additionally accepts an already-loaded tracked graph so -a caller holding one does not force a second load. +The tracked, body-bearing shape remains the read for the two paths that genuinely need +it — the inheritance reconciler (which compares and copies script bodies and writes +through the loaded entities) and the flattener (which must observe rows an in-flight +bundle import has staged on the shared change tracker). The automatic post-mutation +`ReconcileDescendantsAsync` reads only `GetTemplateReconcileClosureAsync` — the edited +template's derived subtree plus its own inheritance chain, tracked and fully included, +one load per subtree depth and chain level (Gitea #86). The reconcile is +inheritance-only, so every descendant's chain lies inside that set; a template with no +children costs a single empty probe query. It still accepts an already-loaded tracked +graph from a caller holding one. The operator `ResyncInheritedMembersAsync` and the +flattener keep reading the whole tracked graph via `GetAllTemplatesAsync`. ### Native Alarm Source Resolution diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs index 68146dd1..89c08489 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs @@ -69,6 +69,20 @@ public interface ITemplateEngineRepository /// A task that resolves to the closure templates without script bodies. Task> GetTemplateAnalysisClosureAsync(int templateId, CancellationToken cancellationToken = default); /// + /// Returns the TRACKED, fully-loaded templates (the shape: + /// attributes, alarms, scripts with bodies, compositions, native alarm sources) that the + /// inherited-member reconcile of 's descendants reads: its whole + /// derived subtree plus its own inheritance chain (see + /// ). + /// Backs TemplateService.ReconcileDescendantsAsync (Gitea #86), which previously + /// loaded every template on each base-member add/update/delete. Returns an empty list when the + /// template has no children — there is nothing to reconcile — after a single probe query. + /// + /// The template whose member set changed. + /// Cancellation token. + /// A task that resolves to the subtree followed by the template's chain. + Task> GetTemplateReconcileClosureAsync(int templateId, CancellationToken cancellationToken = default); + /// /// Returns an untracked identity/edge projection of every template (no children), ordered by name. /// /// Cancellation token. diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Templates/TemplateReconcileClosure.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Templates/TemplateReconcileClosure.cs new file mode 100644 index 00000000..41f52b61 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Templates/TemplateReconcileClosure.cs @@ -0,0 +1,103 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Templates; + +/// +/// Computes the set of templates the inherited-member reconcile reads when a base member of one +/// template changes (Gitea #86): the template's whole derived subtree (children, +/// grandchildren, …) plus the template's own inheritance chain (itself, its parent, +/// grandparent, …). +/// +/// +/// This is the descendant-direction counterpart of (#66). +/// TemplateService.ReconcileDescendantsAsync reconciles every descendant D by +/// resolving D's inheritance chain (TemplateInheritanceResolver.Resolve), which is +/// inheritance only — compositions are never followed. D's chain is the path from +/// D up to the edited template (all inside the subtree) followed by the edited template's +/// own chain, and every member winner originates on that chain. So the subtree plus the root's +/// chain is everything the reconcile can read; the rest of the library is irrelevant. +/// +/// +/// +/// The walk is breadth-first by batch: one loadChildren call per subtree depth and one +/// loadByIds call per ancestor level, so a store-backed loader issues one query per +/// depth rather than one per template. When the template has no children — the common case — +/// the walk stops after the single children probe and returns an empty list, because there is +/// nothing to reconcile. Cycles terminate (each id is loaded at most once) and a dangling parent +/// edge simply ends the chain, exactly as the resolver's chain builder does. +/// +/// +public static class TemplateReconcileClosure +{ + /// + /// Collects the derived subtree and inheritance chain of . + /// + /// The template whose member set changed. + /// Loads every template whose parent is one of the given ids. + /// Loads the templates with the given ids (unknown ids are omitted). + /// Cancellation token. + /// + /// The subtree (breadth-first) followed by the root's chain, each template once; an empty + /// list when the template has no children. + /// + public static async Task> CollectAsync( + int templateId, + Func, CancellationToken, Task>> loadChildren, + Func, CancellationToken, Task>> loadByIds, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(loadChildren); + ArgumentNullException.ThrowIfNull(loadByIds); + + var loaded = new Dictionary(); + var result = new List