From 6070cc2b9d21e852951f89b3808fd24f49f2f6c9 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 9 Nov 2026 13:16:33 -0500 Subject: [PATCH] perf(templates): resync and resolved-members read only the chain/subtree (ScadaBridge#89) The last two whole-library template reads are gone. Both fed the inheritance-only TemplateInheritanceResolver, which walks one template's parent chain and never follows compositions. - TemplateService.ResyncInheritedMembersAsync (operator repair, CLI + editor) now reads #86's GetTemplateReconcileClosureAsync (derived subtree + the template's chain, tracked). When nothing derives from the template the closure is empty, so it falls back to the new tracked GetTemplateChainAsync - resyncing a leaf reads only its own chain. - ManagementActor.HandleGetResolvedTemplateMembers now reads GetTemplateChainAsync(id, trackChanges: false): the chain alone, untracked, with script bodies (the resolver projects script code, so #66's body-free analysis closure would not be equivalent). The chain walk is factored out of TemplateReconcileClosure into CollectChainAsync (one load per level, stops on parent cycles and dangling parents) and shared by both. Evidence: - TemplateChainReadTests (#86 fixture): for every template plus an unknown id, the closure-backed resync issues exactly the writes, audit row and result of the whole-graph resync with GetAllTemplatesAsync wired to throw; the resolver answers identically from the chain and the whole graph. 17 of these fail against the old service; 13 fail with the leaf chain fallback removed (mutation-checked). - TemplateChainRepositoryTests (SQLite, recorded SQL): a 4-deep chain loads 4 templates / 8 attributes instead of 46 / 92, one split-query set per level, untracked on request and tracked for the resync. - ResolvedTemplateMembersReadTests: the actor answers from the untracked chain read and never calls GetAllTemplatesAsync (fails on the old actor). Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV --- docs/requirements/Component-TemplateEngine.md | 9 +- .../Repositories/ITemplateEngineRepository.cs | 16 ++ .../Templates/TemplateReconcileClosure.cs | 40 ++++- .../Repositories/TemplateEngineRepository.cs | 22 ++- .../ManagementActor.cs | 10 +- .../TemplateService.cs | 10 +- .../TemplateChainRepositoryTests.cs | 113 ++++++++++++ .../ResolvedTemplateMembersReadTests.cs | 58 ++++++ .../AnalysisClosureMock.cs | 12 +- .../TemplateChainReadTests.cs | 169 ++++++++++++++++++ .../TemplateReconcileClosureTests.cs | 10 +- 11 files changed, 451 insertions(+), 18 deletions(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/TemplateChainRepositoryTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ResolvedTemplateMembersReadTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateChainReadTests.cs diff --git a/docs/requirements/Component-TemplateEngine.md b/docs/requirements/Component-TemplateEngine.md index ce087ac7..9e6c2f44 100644 --- a/docs/requirements/Component-TemplateEngine.md +++ b/docs/requirements/Component-TemplateEngine.md @@ -233,8 +233,13 @@ template's derived subtree plus its own inheritance chain, tracked and fully inc 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`. +graph from a caller holding one. The operator `ResyncInheritedMembersAsync` reads the +same reconcile closure and, when nothing derives from the template (empty closure), +falls back to `GetTemplateChainAsync` — the template's own inheritance chain, tracked, +one load per level — since resyncing a leaf reads only its chain (Gitea #89). The +read-only resolved-members query (`GetResolvedTemplateMembersCommand`, behind the +editor's inherited view and CLI `template resolved-members`) reads that chain alone, +untracked. The flattener keeps 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 89c08489..091f5396 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs @@ -83,6 +83,22 @@ public interface ITemplateEngineRepository /// A task that resolves to the subtree followed by the template's chain. Task> GetTemplateReconcileClosureAsync(int templateId, CancellationToken cancellationToken = default); /// + /// Returns the fully-loaded templates (the shape: attributes, + /// alarms, scripts with bodies, compositions, native alarm sources) of + /// 's inheritance chain only — the template, its parent, + /// grandparent, … (see + /// ). + /// That is everything TemplateInheritanceResolver.Resolve reads for one template (Gitea #89): + /// it backs the read-only resolved-members query ( = false) and the + /// inherited-member resync of a template with no descendants (tracked, because the resync writes + /// through these entities). Returns an empty list when the template does not exist. + /// + /// The template whose chain to load. + /// Whether the returned entities are tracked by the context. + /// Cancellation token. + /// A task that resolves to the template followed by its ancestors. + Task> GetTemplateChainAsync(int templateId, bool trackChanges, 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 index 41f52b61..f7ef47a4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Templates/TemplateReconcileClosure.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Templates/TemplateReconcileClosure.cs @@ -78,6 +78,44 @@ public static class TemplateReconcileClosure // 2. The root's own inheritance chain, one query per level. A level already loaded as // part of the subtree means a parent cycle — the chain ends there. + await WalkChainAsync(templateId, loaded, result, loadByIds, cancellationToken).ConfigureAwait(false); + return result; + } + + /// + /// Collects only the inheritance chain of : the template itself, + /// its parent, grandparent, … (Gitea #89). This is everything + /// TemplateInheritanceResolver.Resolve reads for one template — the resolver is + /// inheritance-only — so it backs the read-only resolved-members query, and the inherited-member + /// resync of a template nobody derives from. One call per level; + /// a parent cycle or a dangling parent edge ends the chain. + /// + /// The template whose chain to load. + /// Loads the templates with the given ids (unknown ids are omitted). + /// Cancellation token. + /// The template followed by its ancestors, each once; empty when the template is unknown. + public static async Task> CollectChainAsync( + int templateId, + Func, CancellationToken, Task>> loadByIds, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(loadByIds); + var result = new List