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();
+ // The root is "visited" from the start so a cycle back to it does not re-expand it; it is
+ // loaded (if it exists) by the chain walk below.
+ var expanded = new HashSet { templateId };
+
+ // 1. Derived subtree, one children query per depth.
+ var frontier = new List { templateId };
+ while (frontier.Count > 0)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var children = await loadChildren(frontier, cancellationToken).ConfigureAwait(false);
+ var next = new List();
+ foreach (var child in children)
+ {
+ if (child.Id == templateId || !loaded.TryAdd(child.Id, child))
+ continue;
+ result.Add(child);
+ if (expanded.Add(child.Id))
+ next.Add(child.Id);
+ }
+ frontier = next;
+ }
+
+ if (result.Count == 0)
+ return result; // no descendants: nothing to reconcile, so load nothing else
+
+ // 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.
+ var requested = new HashSet();
+ int? nextId = templateId;
+ while (nextId is int id && requested.Add(id))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (loaded.TryGetValue(id, out var already))
+ {
+ nextId = already.ParentTemplateId;
+ continue;
+ }
+
+ var batch = await loadByIds(new[] { id }, cancellationToken).ConfigureAwait(false);
+ var template = batch.FirstOrDefault(t => t.Id == id);
+ if (template is null)
+ break; // dangling parent edge (or an unknown root)
+ loaded[id] = template;
+ result.Add(template);
+ nextId = template.ParentTemplateId;
+ }
+
+ return result;
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs
index f1884cc5..b398b9a3 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs
@@ -80,14 +80,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
///
public async Task> GetAllTemplatesAsync(CancellationToken cancellationToken = default)
{
- return await _context.Templates
- .Include(t => t.Attributes)
- .Include(t => t.Alarms)
- .Include(t => t.Scripts)
- .Include(t => t.Compositions)
- .Include(t => t.NativeAlarmSources)
- .AsSplitQuery()
- .ToListAsync(cancellationToken);
+ return await FullTemplateGraph().ToListAsync(cancellationToken);
}
///
@@ -143,6 +136,44 @@ public class TemplateEngineRepository : ITemplateEngineRepository
cancellationToken);
}
+ ///
+ // One tracked, fully-included query per subtree depth (children of the frontier) and one per
+ // ancestor level — the same entity shape as GetAllTemplatesAsync, because the reconcile
+ // writes through these entities and compares script bodies (Gitea #86).
+ public Task> GetTemplateReconcileClosureAsync(int templateId, CancellationToken cancellationToken = default)
+ {
+ return TemplateReconcileClosure.CollectAsync(
+ templateId,
+ async (parentIds, ct) =>
+ {
+ var idList = parentIds.ToList();
+ return await FullTemplateGraph()
+ .Where(t => t.ParentTemplateId != null && idList.Contains(t.ParentTemplateId.Value))
+ .ToListAsync(ct);
+ },
+ async (ids, ct) =>
+ {
+ var idList = ids.ToList();
+ return await FullTemplateGraph()
+ .Where(t => idList.Contains(t.Id))
+ .ToListAsync(ct);
+ },
+ cancellationToken);
+ }
+
+ // The tracked, fully-included template shape shared by GetAllTemplatesAsync and
+ // GetTemplateReconcileClosureAsync.
+ private IQueryable FullTemplateGraph()
+ {
+ return _context.Templates
+ .Include(t => t.Attributes)
+ .Include(t => t.Alarms)
+ .Include(t => t.Scripts)
+ .Include(t => t.Compositions)
+ .Include(t => t.NativeAlarmSources)
+ .AsSplitQuery();
+ }
+
// The analysis projection shared by GetAllTemplatesForAnalysisAsync and
// GetTemplateAnalysisClosureAsync (see the note on the former for why it is untracked and
// drops TemplateScript.Code).
diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs
index 58c514e1..061486e6 100644
--- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs
@@ -1268,18 +1268,14 @@ public class TemplateService
/// Username for the audit row.
/// Cancellation token.
///
- /// 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 GetAllTemplatesAsync shape — never the no-tracking,
- /// body-free analysis projection. When the graph is
- /// loaded here.
- ///
- ///
- /// Passing it matters because every member Add/Update/Delete on
- /// 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.
- ///
+ /// 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
+ /// GetAllTemplatesAsync shape — never the no-tracking, body-free analysis
+ /// projection. When 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.
///
/// A task that represents the asynchronous operation.
public async Task ReconcileDescendantsAsync(
@@ -1288,7 +1284,12 @@ public class TemplateService
CancellationToken cancellationToken = default,
IReadOnlyList? 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();
var descendants = GetDescendantIdsBreadthFirst(templateId, all);
if (descendants.Count == 0)
return;
diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/TemplateReconcileClosureRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/TemplateReconcileClosureRepositoryTests.cs
new file mode 100644
index 00000000..9b3d92f1
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/TemplateReconcileClosureRepositoryTests.cs
@@ -0,0 +1,125 @@
+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;
+
+///
+/// Gitea #86: reads only a
+/// template's derived subtree + inheritance chain — tracked and fully included, the same shape as
+/// — instead of the whole library.
+/// Asserted against real SQL on SQLite with the reader commands recorded.
+///
+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;
+
+ /// Initializes the test class with an in-memory context and repository.
+ public TemplateReconcileClosureRepositoryTests()
+ {
+ _context = SqliteTestHelper.CreateInMemoryContext(_recorder);
+ _repository = new TemplateEngineRepository(_context, new TemplateGraphWatermark());
+ }
+
+ /// Releases the resources held by this test class.
+ public void Dispose()
+ {
+ _context.Database.CloseConnection();
+ _context.Dispose();
+ }
+
+ private async Task 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 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().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);
+ }
+
+ ///
+ /// 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.
+ ///
+ /// A task that represents the asynchronous operation.
+ [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().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);
+ }
+
+ ///
+ /// The common case — a template nobody derives from — costs one children probe that returns
+ /// no rows, and loads and tracks nothing.
+ ///
+ /// A task that represents the asynchronous operation.
+ [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));
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/AnalysisClosureMock.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/AnalysisClosureMock.cs
index 61db7f66..7cff8c8a 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/AnalysisClosureMock.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/AnalysisClosureMock.cs
@@ -37,4 +37,33 @@ internal static class AnalysisClosureMock
ct);
});
}
+
+ ///
+ /// Gitea #86: the descendant reconcile now asks for the reconcile closure (derived subtree +
+ /// the template's chain) instead of GetAllTemplatesAsync. Wires it to run the real
+ /// closure walk over whatever the full tracked-graph setup returns, so every existing
+ /// propagation scenario exercises the service against the reduced set.
+ ///
+ /// The repository mock.
+ /// Optional sink recording each requested closure root.
+ public static void ServeReconcileClosureFromFullGraph(
+ this Mock repoMock,
+ List? closureRequests = null)
+ {
+ async Task> All(CancellationToken token) =>
+ await repoMock.Object.GetAllTemplatesAsync(token) ?? Array.Empty();
+
+ repoMock
+ .Setup(r => r.GetTemplateReconcileClosureAsync(It.IsAny(), It.IsAny()))
+ .Returns((int templateId, CancellationToken ct) =>
+ {
+ closureRequests?.Add(templateId);
+ return TemplateReconcileClosure.CollectAsync(
+ templateId,
+ async (parentIds, token) => (await All(token))
+ .Where(t => t.ParentTemplateId is int p && parentIds.Contains(p)).ToList(),
+ async (ids, token) => (await All(token)).Where(t => ids.Contains(t.Id)).ToList(),
+ ct);
+ });
+ }
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateAuditShapeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateAuditShapeTests.cs
index 9caa4be1..7ffec76f 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateAuditShapeTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateAuditShapeTests.cs
@@ -24,6 +24,7 @@ public class TemplateAuditShapeTests
{
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny())).ReturnsAsync(1);
_repoMock.ServeClosureFromFullGraph();
+ _repoMock.ServeReconcileClosureFromFullGraph();
_auditMock
.Setup(a => a.LogAsync(It.IsAny(), It.IsAny(), It.IsAny(),
It.IsAny(), It.IsAny(), It.IsAny