diff --git a/docs/requirements/Component-Transport.md b/docs/requirements/Component-Transport.md
index 9345cdf9..579a40b4 100644
--- a/docs/requirements/Component-Transport.md
+++ b/docs/requirements/Component-Transport.md
@@ -301,6 +301,8 @@ Site identifiers and connection names are environment-specific, so a bundle expo
**Auto-match → operator override → apply.** The importer first auto-matches every source site and connection against the target by these identity keys. The operator confirms/overrides via the Central UI **Map step** (Step 3) or the CLI `--map-site` / `--map-connection` / `--create-missing-*` flags. References that neither auto-match nor are set to Create-new become **blocker rows** (UI) — or, on the CLI without `--create-missing-sites` / `--create-missing-connections`, abort before apply.
+**Template library reads (review 2026-10-21 04/P4 + 08/P1).** A preview and each apply attempt read the target template library **once**, through body-free projections (`GetTemplateStubsAsync` for id/name maps, `GetAllTemplatesForAnalysisAsync` — untracked, members and lock flags but no script bodies — where members or edges are needed), held on a per-session `TemplateLibrary` and threaded to every consumer: preview diff names, blocker and locked-override scans, apply-time validation, the composition/inheritance rewire fallbacks (formerly a full library reload *inside* the per-template loop), the instance pass's name→id map (overlaid with the change tracker's just-flushed templates), the acyclicity guard and the stale-instance expansion. The full tracked graph is loaded only for the templates the bundle **Overwrites** (`GetTemplatesWithChildrenAsync` by name), since their child syncs mutate it. The acyclicity guard reads the change tracker's staged templates overlaid on that snapshot — the untouched part of the graph has not changed during the apply. Instance lookups are batched by name set (`ITemplateEngineRepository.GetInstancesByUniqueNamesAsync`, one tracked + hydrated read for preview and one for the instance pass, Rename targets included), and the preview resolves an existing instance's template / site / area names from the library, one site list and one area list per site, and reads shared scripts and external systems once each.
+
**Template second-pass rewires.** Bundles carry template-to-template references by **name**, not id (ids are environment-specific). After the templates are flushed, the importer runs three name-resolution passes over the imported templates so every cross-template FK points at the freshly-persisted target row: the **alarm on-trigger-script** links (`TemplateAlarm.OnTriggerScriptId`), the **composition** edges (`TemplateComposition`), and the **inheritance** edges (`Template.ParentTemplateId`, resolved from `BaseTemplateName` through the same rename/skip resolution map — a `null` base clears a stale edge on Overwrite). Each pass is best-effort: an unresolvable reference leaves the FK null and emits a warning audit row (`BundleImportAlarmScriptUnresolved` / `BundleImportCompositionUnresolved` / `BundleImportBaseTemplateUnresolved`) rather than aborting the import.
**Rename call-site limitation (load-bearing).** A `Rename` resolution renames the entity being imported (e.g. a SharedScript `Foo` → `Bar`), but it does **not** rewrite call sites inside importing scripts — a script body that calls `CallShared("Foo")` still says `Foo` after import. So the target DB can end up with scripts referencing the old name. This is deliberate: Pass 1 name resolution registers **both** the original and renamed names as resolvable (so the import isn't blocked), and the authoritative check is the target's own **deploy-time validation** (`ScriptCompiler.TryCompile` in the Template Engine deploy gate), which recompiles against the actual persisted catalog. Operators renaming a shared dependency must fix the calling scripts on the target (or re-export with the dependency included under its original name).
@@ -314,7 +316,7 @@ Site identifiers and connection names are environment-specific, so a bundle expo
There is no explicit stale-mark write. Overwriting a template during import changes its content, which naturally changes the flattened-config hash that `DeploymentService.CompareAsync` computes at query time. The Deployments page already surfaces any instance whose computed hash differs from the deployed snapshot — no new mechanism is required.
-As of M8 the import result enumerates affected instances for real (closing the prior `StaleInstanceIds` stub). Before commit, for each **Overwritten** template the importer walks the existing **deployed** target instances of that template and, via the `IStaleInstanceProbe` seam in Commons (implemented in the Deployment Manager over the flattening pipeline), compares each instance's freshly-flattened revision hash against its `DeployedConfigSnapshot.RevisionHash`; instances whose hash has drifted are returned in `ImportResult.StaleInstanceIds`. The probe is optional — when it is not registered the result is an empty set (informational only, never blocking). Freshly-imported `NotDeployed` instances are surfaced as **new**, not stale. Step 5 (Confirm) shows the real count and Step 6 (Result) deep-links the Deployments page filtered to those instances.
+As of M8 the import result enumerates affected instances for real (closing the prior `StaleInstanceIds` stub). Before commit the importer takes the set of **Overwritten** templates and — review 2026-10-21 04/C2 — expands it over the post-apply template graph to every template whose flattened config includes one of them: **derived templates** (via `ParentTemplateId`) and **templates that compose it** (via `TemplateComposition.ComposedTemplateId`), transitively. Overwriting a base or a feature module with no direct instances therefore still reports the deployed instances of everything built on it (previously only instances whose `TemplateId` was the overwritten id were probed, so such an import reported 0 stale instances and the operator was told nothing needed redeploying). It then walks the **deployed** target instances of every affected template and, via the `IStaleInstanceProbe` seam in Commons (implemented in the Deployment Manager over the flattening pipeline), compares each instance's freshly-flattened revision hash against its latest `DeployedConfigSnapshot.RevisionHash` (read for all candidates in one projected query); instances whose hash has drifted are returned in `ImportResult.StaleInstanceIds`. The probe is used through `IStaleInstanceProbe.BeginBatch()`, which shares one `FlattenSession` across the whole check so a template chain common to many instances is walked once. The probe is optional — when it is not registered the result is an empty set (informational only, never blocking). Freshly-imported `NotDeployed` instances are surfaced as **new**, not stale. Step 5 (Confirm) shows the real count and Step 6 (Result) deep-links the Deployments page filtered to those instances.
## Error Handling
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 0a2df109..5cdc8c15 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs
@@ -262,6 +262,15 @@ public interface ITemplateEngineRepository
/// Cancellation token.
/// A task that resolves to the matching instance, or if not found.
Task GetInstanceByUniqueNameAsync(string uniqueName, CancellationToken cancellationToken = default);
+ ///
+ /// Retrieves every instance whose unique name is in , with the
+ /// same four child collections includes, in one
+ /// round trip (tracked). Unknown names are simply absent from the result.
+ ///
+ /// The unique instance names to look up; null/empty/duplicate entries are ignored.
+ /// Cancellation token.
+ /// A task that resolves to the matching instances.
+ Task> GetInstancesByUniqueNamesAsync(IEnumerable uniqueNames, CancellationToken cancellationToken = default);
/// Adds a new instance.
/// The instance to add.
/// Cancellation token.
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/IStaleInstanceProbe.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/IStaleInstanceProbe.cs
index 0bcdadb7..76885599 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/IStaleInstanceProbe.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/IStaleInstanceProbe.cs
@@ -29,4 +29,14 @@ public interface IStaleInstanceProbe
/// Cancellation token.
/// The current revision hash (e.g. sha256:...), or null.
Task GetCurrentRevisionHashAsync(int instanceId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Returns a probe for hashing a batch of instances that may share template
+ /// chains — the implementation can reuse one flatten session across the batch so
+ /// a chain is walked once, not once per instance (review 2026-10-21 04/C2). Use
+ /// the returned probe for one batch only, within the caller's scope. The default
+ /// is this probe itself (no sharing).
+ ///
+ /// A probe to use for the batch.
+ IStaleInstanceProbe BeginBatch() => this;
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs
index 5ed6e36d..9d378d33 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs
@@ -476,6 +476,30 @@ public class TemplateEngineRepository : ITemplateEngineRepository
.FirstOrDefaultAsync(i => i.UniqueName == uniqueName, cancellationToken);
}
+ ///
+ public async Task> GetInstancesByUniqueNamesAsync(
+ IEnumerable uniqueNames, CancellationToken cancellationToken = default)
+ {
+ // Bulk variant for the bundle importer's preview + instance pass, which used to
+ // call GetInstanceByUniqueNameAsync once per bundled instance (review 2026-10-21
+ // 04/P4). De-duplicate before the query so EF emits a clean IN list.
+ if (uniqueNames is null) return Array.Empty();
+ var distinct = uniqueNames
+ .Where(n => !string.IsNullOrEmpty(n))
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+ if (distinct.Length == 0) return Array.Empty();
+
+ return await _context.Instances
+ .Where(i => distinct.Contains(i.UniqueName))
+ .Include(i => i.AttributeOverrides)
+ .Include(i => i.AlarmOverrides)
+ .Include(i => i.ConnectionBindings)
+ .Include(i => i.NativeAlarmSourceOverrides)
+ .AsSplitQuery()
+ .ToListAsync(cancellationToken);
+ }
+
///
public async Task AddInstanceAsync(Instance instance, CancellationToken cancellationToken = default)
{
diff --git a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/StaleInstanceProbe.cs b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/StaleInstanceProbe.cs
index 1dc2ec67..dc906c77 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/StaleInstanceProbe.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/StaleInstanceProbe.cs
@@ -24,4 +24,33 @@ public sealed class StaleInstanceProbe : IStaleInstanceProbe
instanceId, _flatteningPipeline.CreateSession(), cancellationToken).ConfigureAwait(false);
return result.IsSuccess ? result.Value : null;
}
+
+ ///
+ ///
+ /// The batch probe shares ONE across its calls, so an
+ /// import that stale-checks many instances of the same (or related) templates walks
+ /// each template chain once (review 2026-10-21 04/C2). The session caches only what
+ /// it read during this batch, so it is no staler than the per-call session.
+ ///
+ public IStaleInstanceProbe BeginBatch() => new BatchProbe(_flatteningPipeline, _flatteningPipeline.CreateSession());
+
+ private sealed class BatchProbe : IStaleInstanceProbe
+ {
+ private readonly IFlatteningPipeline _pipeline;
+ private readonly FlattenSession _session;
+
+ public BatchProbe(IFlatteningPipeline pipeline, FlattenSession session)
+ {
+ _pipeline = pipeline;
+ _session = session;
+ }
+
+ public async Task GetCurrentRevisionHashAsync(int instanceId, CancellationToken cancellationToken = default)
+ {
+ var result = await _pipeline.ComputeRevisionHashAsync(instanceId, _session, cancellationToken).ConfigureAwait(false);
+ return result.IsSuccess ? result.Value : null;
+ }
+
+ public IStaleInstanceProbe BeginBatch() => this;
+ }
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs
index 126f08b7..5531bd49 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs
@@ -22,6 +22,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Validation;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
@@ -450,6 +451,10 @@ public sealed class BundleImporter : IBundleImporter
var content = ReadSessionContent(session);
+ // Review 2026-10-21 04/P4 + 08/P1: the template library is read once per
+ // preview through body-free projections, never as the full tracked graph.
+ var library = new TemplateLibrary(_templateRepo);
+
var items = new List();
// ---- TemplateFolders ----
@@ -473,8 +478,7 @@ public sealed class BundleImporter : IBundleImporter
.ConfigureAwait(false);
var hydratedByName = hydratedTemplates
.ToDictionary(t => t.Name, t => t, StringComparer.Ordinal);
- var allTemplateStubs = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
- var templateNameById = allTemplateStubs.ToDictionary(t => t.Id, t => t.Name);
+ var templateNameById = await library.GetNameByIdAsync(ct).ConfigureAwait(false);
foreach (var tDto in content.Templates)
{
hydratedByName.TryGetValue(tDto.Name, out var existing);
@@ -482,16 +486,26 @@ public sealed class BundleImporter : IBundleImporter
}
// ---- SharedScripts ----
+ // One read of each library (04/P4); DetectBlockersAsync reuses the same lists
+ // for its known-resolvable name sets instead of reading them again.
+ var allSharedScripts = await _templateRepo.GetAllSharedScriptsAsync(ct).ConfigureAwait(false);
+ var sharedScriptByName = allSharedScripts
+ .GroupBy(x => x.Name, StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
foreach (var s in content.SharedScripts)
{
- var existing = await _templateRepo.GetSharedScriptByNameAsync(s.Name, ct).ConfigureAwait(false);
+ sharedScriptByName.TryGetValue(s.Name, out var existing);
items.Add(_diff.CompareSharedScript(s, existing));
}
// ---- ExternalSystems (+ their methods) ----
+ var allExternalSystems = await _externalRepo.GetAllExternalSystemsAsync(ct).ConfigureAwait(false);
+ var externalSystemByName = allExternalSystems
+ .GroupBy(x => x.Name, StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
foreach (var es in content.ExternalSystems)
{
- var existing = await _externalRepo.GetExternalSystemByNameAsync(es.Name, ct).ConfigureAwait(false);
+ externalSystemByName.TryGetValue(es.Name, out var existing);
IReadOnlyList? methods = null;
if (existing is not null)
{
@@ -622,15 +636,25 @@ public sealed class BundleImporter : IBundleImporter
}
// ---- Instances (hydrated target + resolved template/site/area names; review item I2) ----
+ // GetInstancesByUniqueNamesAsync eagerly Includes all four child nav
+ // collections (AttributeOverrides / AlarmOverrides / ConnectionBindings /
+ // NativeAlarmSourceOverrides), so the entity handed to CompareInstance is
+ // HYDRATED — its children diff correctly instead of every incoming child
+ // reading as an addition (review item I2). Review 2026-10-21 04/P4: one
+ // batched read for every bundled instance, and the template / site / area
+ // names come from the library projection, one site list and one area list
+ // per site — not four queries per instance.
+ var existingInstanceByName = content.Instances.Count == 0
+ ? new Dictionary(StringComparer.Ordinal)
+ : (await _templateRepo
+ .GetInstancesByUniqueNamesAsync(content.Instances.Select(i => i.UniqueName), ct)
+ .ConfigureAwait(false))
+ .ToDictionary(i => i.UniqueName, i => i, StringComparer.Ordinal);
+ Dictionary? siteIdentifierById = null;
+ var areaNameByIdBySite = new Dictionary>();
foreach (var instDto in content.Instances)
{
- // GetInstanceByUniqueNameAsync eagerly Includes all four child nav
- // collections (AttributeOverrides / AlarmOverrides / ConnectionBindings /
- // NativeAlarmSourceOverrides), so the entity handed to CompareInstance is
- // HYDRATED — its children diff correctly instead of every incoming child
- // reading as an addition (review item I2).
- var existing = await _templateRepo
- .GetInstanceByUniqueNameAsync(instDto.UniqueName, ct).ConfigureAwait(false);
+ existingInstanceByName.TryGetValue(instDto.UniqueName, out var existing);
string? existingTemplateName = null;
string? existingSiteIdentifier = null;
@@ -640,16 +664,21 @@ public sealed class BundleImporter : IBundleImporter
// The entity stores template/site/area as numeric FKs that can't be
// compared cross-environment, so resolve each to the same stable
// string identity the incoming DTO carries.
- var tmpl = await _templateRepo.GetTemplateByIdAsync(existing.TemplateId, ct).ConfigureAwait(false);
- existingTemplateName = tmpl?.Name;
+ existingTemplateName = templateNameById.TryGetValue(existing.TemplateId, out var tn) ? tn : null;
- var site = await _siteRepo.GetSiteByIdAsync(existing.SiteId, ct).ConfigureAwait(false);
- existingSiteIdentifier = site?.SiteIdentifier;
+ siteIdentifierById ??= (await _siteRepo.GetAllSitesAsync(ct).ConfigureAwait(false))
+ .ToDictionary(x => x.Id, x => x.SiteIdentifier);
+ existingSiteIdentifier = siteIdentifierById.TryGetValue(existing.SiteId, out var si) ? si : null;
if (existing.AreaId is int areaId)
{
- var area = await _templateRepo.GetAreaByIdAsync(areaId, ct).ConfigureAwait(false);
- existingAreaName = area?.Name;
+ if (!areaNameByIdBySite.TryGetValue(existing.SiteId, out var areaNames))
+ {
+ areaNames = (await _templateRepo.GetAreasBySiteIdAsync(existing.SiteId, ct).ConfigureAwait(false))
+ .ToDictionary(a => a.Id, a => a.Name);
+ areaNameByIdBySite[existing.SiteId] = areaNames;
+ }
+ existingAreaName = areaNames.TryGetValue(areaId, out var an) ? an : null;
}
}
@@ -662,7 +691,8 @@ public sealed class BundleImporter : IBundleImporter
content, ResolveTargetSiteAsync, ResolveTargetConnectionsAsync).ConfigureAwait(false);
// ---- Blocker detection ----
- items.AddRange(await DetectBlockersAsync(content, ResolveTargetConnectionsAsync, ct).ConfigureAwait(false));
+ items.AddRange(await DetectBlockersAsync(
+ content, ResolveTargetConnectionsAsync, library, allSharedScripts, allExternalSystems, ct).ConfigureAwait(false));
return new ImportPreview(sessionId, items, requiredSites, requiredConnections);
}
@@ -776,6 +806,9 @@ public sealed class BundleImporter : IBundleImporter
private async Task> DetectBlockersAsync(
BundleContentDto content,
Func>> resolveTargetConnections,
+ TemplateLibrary library,
+ IReadOnlyList allSharedScripts,
+ IReadOnlyList allExternalSystems,
CancellationToken ct)
{
var blockers = new List();
@@ -790,7 +823,9 @@ public sealed class BundleImporter : IBundleImporter
// its new name, but at preview time no resolution has been chosen yet, so
// the in-bundle name is the original DTO name — which is what an instance
// references. (Explicit rename remap is a D-wave apply-time concern.)
- var targetTemplates = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
+ // Members + lock flags are needed below (CollectLockedOverrideWarnings) but
+ // never script bodies — the body-free, untracked analysis projection (04/P4).
+ var targetTemplates = await library.GetAnalysisAsync(ct).ConfigureAwait(false);
var targetTemplateNames = new HashSet(StringComparer.Ordinal);
foreach (var t in targetTemplates)
{
@@ -863,8 +898,6 @@ public sealed class BundleImporter : IBundleImporter
}
// Known-resolvable names = (in-bundle) ∪ (already-in-target).
- var allSharedScripts = await _templateRepo.GetAllSharedScriptsAsync(ct).ConfigureAwait(false);
- var allExternalSystems = await _externalRepo.GetAllExternalSystemsAsync(ct).ConfigureAwait(false);
var sharedScriptNames = new HashSet(StringComparer.Ordinal);
foreach (var s in content.SharedScripts) sharedScriptNames.Add(s.Name);
@@ -1723,8 +1756,16 @@ public sealed class BundleImporter : IBundleImporter
// Skip-resolved DTOs are excluded from the in-bundle name set so
// a Skip on a dependency surfaces as a missing-reference error
// rather than silently passing.
+ // Review 2026-10-21 04/P4 + 08/P1: the template library is read ONCE per
+ // apply attempt — here, before anything is staged — as the untracked,
+ // body-free analysis projection, and threaded through every pass that only
+ // needs ids / names / members. The full tracked graph is loaded only for the
+ // templates this bundle Overwrites (ApplyTemplatesAsync).
+ var library = new TemplateLibrary(_templateRepo);
+ await library.GetAnalysisAsync(ct).ConfigureAwait(false);
+
var (validationErrors, validationWarnings) =
- await RunSemanticValidationAsync(content, resolutionMap, ct).ConfigureAwait(false);
+ await RunSemanticValidationAsync(content, resolutionMap, library, ct).ConfigureAwait(false);
// Validate every site / connection / template reference the
// site-instance payload depends on BEFORE any row is staged. Running
// this in the validation phase (not as an apply-pass guard) preserves
@@ -1735,7 +1776,7 @@ public sealed class BundleImporter : IBundleImporter
// guards, but in normal operation those never fire.
validationErrors = validationErrors.Count > 0
? validationErrors
- : await ValidateSiteInstanceReferencesAsync(content, resolutionMap, nameMap, ct).ConfigureAwait(false);
+ : await ValidateSiteInstanceReferencesAsync(content, resolutionMap, nameMap, library, ct).ConfigureAwait(false);
// Review 2026-10-21 03/R-1 + lane-F follow-up: values the runtime later
// trusts blindly — site gRPC endpoints and names that become actor names —
@@ -1818,8 +1859,8 @@ public sealed class BundleImporter : IBundleImporter
// write itself.
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
await ResolveAlarmScriptLinksAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
- await ResolveCompositionEdgesAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
- await ResolveInheritanceEdgesAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
+ await ResolveCompositionEdgesAsync(content.Templates, resolutionMap, library, user, ct).ConfigureAwait(false);
+ await ResolveInheritanceEdgesAsync(content.Templates, resolutionMap, library, user, ct).ConfigureAwait(false);
// Folder parent edges are name-keyed too — resolve them AFTER the flush
// above so every imported folder has a materialised id to point at
// (the Add pass created the folders parent-less). Mirrors the template
@@ -1842,7 +1883,7 @@ public sealed class BundleImporter : IBundleImporter
// catch below rolls the whole import back (ChangeTracker.Clear on the
// in-memory provider) BEFORE any of it is persisted; deliberately no
// SaveChanges precedes this check so nothing survives that rollback.
- EnsureNoTemplateGraphCycles();
+ EnsureNoTemplateGraphCycles(await library.GetAnalysisAsync(ct).ConfigureAwait(false));
// ---- Site/instance-scoped apply: instances LAST ----
// The instance pass needs every FK target materialised first:
@@ -1857,7 +1898,7 @@ public sealed class BundleImporter : IBundleImporter
// rewires every name-keyed FK to the target environment's ids.
await ApplyInstancesAsync(
content, resolutionMap, siteBySourceIdentifier, connectionMaps,
- user, summary, ct).ConfigureAwait(false);
+ library, user, summary, ct).ConfigureAwait(false);
// ---- Pre-commit point: stale-instance computation ----
// Everything the import will write is staged on the change tracker at
@@ -1869,7 +1910,7 @@ public sealed class BundleImporter : IBundleImporter
// is the next statement, so a read against the change tracker here sees
// the full post-apply graph before commit.
var staleInstanceIds = await ComputeStaleInstanceIdsAsync(
- content, resolutionMap, ct).ConfigureAwait(false);
+ content, resolutionMap, library, ct).ConfigureAwait(false);
await _auditService.LogAsync(
user: user,
@@ -2012,14 +2053,21 @@ public sealed class BundleImporter : IBundleImporter
///
/// Enumerates the deployed instances stale-ed by this import.
/// Overwriting a template changes the flattened-config hash of every instance
- /// of that template, so a deployed instance's freshly-computed revision hash
+ /// built from it, so a deployed instance's freshly-computed revision hash
/// will no longer match the hash captured in its DeployedConfigSnapshot
- /// at deploy time — it surfaces as "stale" on the Deployments page. This walks
- /// the templates THIS import OVERWROTE, finds each one's deployed instances
+ /// at deploy time — it surfaces as "stale" on the Deployments page. This starts
+ /// from the templates THIS import OVERWROTE and — review 2026-10-21 04/C2 —
+ /// expands that set over the post-apply template graph to every template whose
+ /// flattened config includes an overwritten one: descendants through
+ /// ParentTemplateId and templates composing it through
+ /// TemplateComposition.ComposedTemplateId, transitively (a template that
+ /// composes a derived template of an overwritten base is affected too). It then
+ /// finds each affected template's deployed instances
/// (State /
- /// with a snapshot), recomputes their current hash through
- /// (which reads the just-staged template
- /// rows on the shared change tracker), and collects the ids whose hash drifted.
+ /// with a snapshot), recomputes their current hash through ONE batch of
+ /// (a single flatten session, so a shared
+ /// template chain is walked once — the probe reads the just-staged template rows
+ /// on the shared change tracker), and collects the ids whose hash drifted.
///
/// Freshly-IMPORTED instances are excluded structurally: they enter the target
/// (no snapshot), so they're "new", not
@@ -2032,6 +2080,7 @@ public sealed class BundleImporter : IBundleImporter
private async Task> ComputeStaleInstanceIdsAsync(
BundleContentDto content,
Dictionary<(string EntityType, string Name), ImportResolution> resolutionMap,
+ TemplateLibrary library,
CancellationToken ct)
{
// No flattening pipeline registered → can't recompute hashes. Staleness is
@@ -2043,11 +2092,10 @@ public sealed class BundleImporter : IBundleImporter
// Which templates did THIS import overwrite? A bundle template is
// overwritten when its resolution Action == Overwrite AND a target template
- // of that name already exists (otherwise the Overwrite branch falls through
- // to Add — see ApplyTemplatesAsync). Map those names to their target ids.
- var targetTemplateIdByName = (await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false))
- .GroupBy(t => t.Name, StringComparer.Ordinal)
- .ToDictionary(g => g.Key, g => g.First().Id, StringComparer.Ordinal);
+ // of that name already existed (otherwise the Overwrite branch falls through
+ // to Add — see ApplyTemplatesAsync). The pre-write library maps those names
+ // to their (unchanged) target ids.
+ var targetTemplateIdByName = await library.GetIdByNameAsync(ct).ConfigureAwait(false);
var overwrittenTemplateIds = new HashSet();
foreach (var dto in content.Templates)
@@ -2065,57 +2113,118 @@ public sealed class BundleImporter : IBundleImporter
return Array.Empty();
}
- var staleIds = new List();
- foreach (var templateId in overwrittenTemplateIds)
+ var affectedTemplateIds = ExpandToDependentTemplates(
+ overwrittenTemplateIds, MergeTemplateGraph(await library.GetAnalysisAsync(ct).ConfigureAwait(false)));
+
+ // Candidate instances: deployed instances of any affected template. The
+ // repository read is tracked, so an instance this import just reset to
+ // NotDeployed reads back with its staged state and is skipped as "new".
+ var candidates = new List<(int InstanceId, int TemplateId)>();
+ foreach (var templateId in affectedTemplateIds)
{
var instances = await _templateRepo
.GetInstancesByTemplateIdAsync(templateId, ct).ConfigureAwait(false);
foreach (var instance in instances)
{
- // NotDeployed instances (including everything this import just
- // created/overwrote) are "new", not "stale" — skip them.
- if (instance.State is not (InstanceState.Enabled or InstanceState.Disabled))
+ if (instance.State is InstanceState.Enabled or InstanceState.Disabled)
{
- continue;
- }
-
- try
- {
- // A deployed instance has a snapshot; its absence means the
- // instance is not actually deployed (defensive — State already
- // gated this) so there's no baseline to compare against.
- var snapshot = await _dbContext.DeployedConfigSnapshots
- .AsNoTracking()
- .Where(s => s.InstanceId == instance.Id)
- .OrderByDescending(s => s.DeployedAt)
- .FirstOrDefaultAsync(ct)
- .ConfigureAwait(false);
- if (snapshot is null) continue;
-
- var currentHash = await _staleInstanceProbe
- .GetCurrentRevisionHashAsync(instance.Id, ct).ConfigureAwait(false);
- // null => hash indeterminate; don't claim staleness either way.
- if (currentHash is null) continue;
-
- if (!string.Equals(currentHash, snapshot.RevisionHash, StringComparison.Ordinal))
- {
- staleIds.Add(instance.Id);
- }
- }
- catch (Exception ex)
- {
- // Best-effort: a single instance's hash failure must not abort
- // the (already-successful) import. Skip it and move on.
- _logger?.LogWarning(ex,
- "Stale-instance hash computation failed for instance {InstanceId} (template {TemplateId}); skipping.",
- instance.Id, templateId);
+ candidates.Add((instance.Id, templateId));
}
}
}
+ if (candidates.Count == 0)
+ {
+ return Array.Empty();
+ }
+
+ // Latest snapshot hash per candidate in one projected read (no config JSON).
+ // A deployed instance has a snapshot; its absence means the instance is not
+ // actually deployed (defensive — State already gated this), so there is no
+ // baseline to compare against.
+ var candidateIds = candidates.Select(c => c.InstanceId).ToList();
+ var snapshotRows = await _dbContext.DeployedConfigSnapshots
+ .AsNoTracking()
+ .Where(s => candidateIds.Contains(s.InstanceId))
+ .Select(s => new { s.InstanceId, s.DeployedAt, s.RevisionHash })
+ .ToListAsync(ct)
+ .ConfigureAwait(false);
+ var deployedHashByInstance = snapshotRows
+ .GroupBy(r => r.InstanceId)
+ .ToDictionary(g => g.Key, g => g.OrderByDescending(r => r.DeployedAt).First().RevisionHash);
+
+ var probe = _staleInstanceProbe.BeginBatch();
+ var staleIds = new List();
+ foreach (var (instanceId, templateId) in candidates)
+ {
+ if (!deployedHashByInstance.TryGetValue(instanceId, out var deployedHash)) continue;
+ try
+ {
+ var currentHash = await probe.GetCurrentRevisionHashAsync(instanceId, ct).ConfigureAwait(false);
+ // null => hash indeterminate; don't claim staleness either way.
+ if (currentHash is null) continue;
+
+ if (!string.Equals(currentHash, deployedHash, StringComparison.Ordinal))
+ {
+ staleIds.Add(instanceId);
+ }
+ }
+ catch (Exception ex)
+ {
+ // Best-effort: a single instance's hash failure must not abort
+ // the (already-successful) import. Skip it and move on.
+ _logger?.LogWarning(ex,
+ "Stale-instance hash computation failed for instance {InstanceId} (template {TemplateId}); skipping.",
+ instanceId, templateId);
+ }
+ }
+
return staleIds;
}
+ ///
+ /// Review 2026-10-21 04/C2: closes over the
+ /// "my flattened config includes yours" relation — a template depends on its
+ /// base (ParentTemplateId) and on every module it composes — so the result
+ /// is every template whose flattened output can change when a seed changes.
+ ///
+ /// The templates whose content changed.
+ /// The post-apply template graph.
+ /// The seeds plus every transitive dependent.
+ internal static HashSet ExpandToDependentTemplates(
+ IEnumerable seedTemplateIds, IReadOnlyList graph)
+ {
+ var dependentsOf = new Dictionary>();
+ void AddEdge(int dependency, int dependent)
+ {
+ if (!dependentsOf.TryGetValue(dependency, out var list))
+ {
+ list = new List();
+ dependentsOf[dependency] = list;
+ }
+ list.Add(dependent);
+ }
+
+ foreach (var t in graph)
+ {
+ if (t.ParentTemplateId is int parentId) AddEdge(parentId, t.Id);
+ foreach (var c in t.Compositions) AddEdge(c.ComposedTemplateId, t.Id);
+ }
+
+ var affected = new HashSet(seedTemplateIds);
+ var queue = new Queue(affected);
+ while (queue.Count > 0)
+ {
+ if (!dependentsOf.TryGetValue(queue.Dequeue(), out var dependents)) continue;
+ foreach (var d in dependents)
+ {
+ if (affected.Add(d)) queue.Enqueue(d);
+ }
+ }
+
+ return affected;
+ }
+
/// Mutable per-apply counter struct, accumulated through every helper.
private sealed class ImportSummary
{
@@ -2205,8 +2314,17 @@ public sealed class BundleImporter : IBundleImporter
CancellationToken ct)
{
if (dtos.Count == 0) return;
- var stubs = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
- var byName = stubs.ToDictionary(t => t.Name, t => t, StringComparer.Ordinal);
+ // Only an Overwrite needs the existing target row, and it needs it tracked
+ // and fully hydrated (the child syncs mutate it). Load exactly those rows in
+ // one bulk read instead of the whole library (review 2026-10-21 04/P4).
+ var overwriteNames = dtos
+ .Where(d => ResolveOrDefault(map, "Template", d.Name).Action == ResolutionAction.Overwrite)
+ .Select(d => d.Name)
+ .ToList();
+ var byName = (overwriteNames.Count == 0
+ ? Array.Empty()
+ : await _templateRepo.GetTemplatesWithChildrenAsync(overwriteNames, ct).ConfigureAwait(false))
+ .ToDictionary(t => t.Name, t => t, StringComparer.Ordinal);
// Folder ids must be materialised before we wire templates' FolderId
// FKs. ApplyTemplateFoldersAsync staged the rows via AddFolderAsync
@@ -2985,6 +3103,7 @@ public sealed class BundleImporter : IBundleImporter
private async Task ResolveCompositionEdgesAsync(
IReadOnlyList dtos,
Dictionary<(string, string), ImportResolution> resolutionMap,
+ TemplateLibrary library,
string user,
CancellationToken ct)
{
@@ -3045,14 +3164,17 @@ public sealed class BundleImporter : IBundleImporter
// tracked Local set (which includes anything just imported,
// pre-flush or post-flush), then against the wider target DB
// (pre-existing rows not staged in this transaction).
+ // The fallback is a dictionary lookup into the per-apply library
+ // snapshot (04/P4) — a name not in Local is a pre-existing target
+ // template this apply has not touched, so its pre-write id is current.
var composedStub = _dbContext.Templates.Local.FirstOrDefault(t =>
string.Equals(t.Name, compDto.ComposedTemplateName, StringComparison.Ordinal));
int? composedId = composedStub?.Id;
- if (composedId is null)
+ if (composedId is null
+ && (await library.GetIdByNameAsync(ct).ConfigureAwait(false))
+ .TryGetValue(compDto.ComposedTemplateName, out var libraryId))
{
- var allTargets = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
- composedId = allTargets
- .FirstOrDefault(t => string.Equals(t.Name, compDto.ComposedTemplateName, StringComparison.Ordinal))?.Id;
+ composedId = libraryId;
}
if (composedId is null)
@@ -3097,6 +3219,7 @@ public sealed class BundleImporter : IBundleImporter
private async Task ResolveInheritanceEdgesAsync(
IReadOnlyList dtos,
Dictionary<(string, string), ImportResolution> resolutionMap,
+ TemplateLibrary library,
string user,
CancellationToken ct)
{
@@ -3136,11 +3259,10 @@ public sealed class BundleImporter : IBundleImporter
var baseStub = _dbContext.Templates.Local.FirstOrDefault(t =>
string.Equals(t.Name, baseName, StringComparison.Ordinal));
int? baseId = baseStub?.Id;
- if (baseId is null)
+ if (baseId is null
+ && (await library.GetIdByNameAsync(ct).ConfigureAwait(false)).TryGetValue(baseName, out var libraryId))
{
- var allTargets = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
- baseId = allTargets
- .FirstOrDefault(t => string.Equals(t.Name, baseName, StringComparison.Ordinal))?.Id;
+ baseId = libraryId;
}
if (baseId is null)
@@ -3179,19 +3301,19 @@ public sealed class BundleImporter : IBundleImporter
/// on the first cycle found; the caller's catch rolls the whole import back.
///
///
- /// The template list is taken from the change tracker rather than a fresh query
- /// so it reflects the staged edges without a premature flush. It is complete for
- /// cycle detection: any edge that references a pre-existing target template forces
- /// a full GetAllTemplatesAsync load in the rewire passes (tracking every
- /// template), and any all-imported cycle consists solely of tracked (Local)
- /// templates — so every node of any candidate cycle is present.
+ /// The graph is : the change tracker's templates
+ /// (staged, not-yet-saved edges included, without a premature flush) overlaid on
+ /// the per-apply library snapshot for every template the apply has not touched.
+ /// Review 2026-10-21 04/P4: completeness used to rest on a full tracked
+ /// GetAllTemplatesAsync load putting every template on the change
+ /// tracker; the untouched part of the graph now comes from the snapshot instead,
+ /// whose edges are current because the apply never stages a change to a template
+ /// it has not loaded.
///
- private void EnsureNoTemplateGraphCycles()
+ /// The pre-write analysis snapshot of the template library.
+ private void EnsureNoTemplateGraphCycles(IReadOnlyList library)
{
- var templates = _dbContext.ChangeTracker.Entries()
- .Where(e => e.State != EntityState.Deleted)
- .Select(e => e.Entity)
- .ToList();
+ var templates = MergeTemplateGraph(library);
if (templates.Count == 0) return;
foreach (var t in templates)
@@ -3223,6 +3345,87 @@ public sealed class BundleImporter : IBundleImporter
}
}
+ ///
+ /// The post-apply template graph: every non-deleted template on the change
+ /// tracker (staged edges included) plus every library-snapshot template that is
+ /// not tracked. Used by the acyclicity guard and the stale-instance expansion.
+ ///
+ /// The pre-write analysis snapshot of the template library.
+ /// One per id.
+ private List MergeTemplateGraph(IReadOnlyList library)
+ {
+ var templates = _dbContext.ChangeTracker.Entries()
+ .Where(e => e.State != EntityState.Deleted)
+ .Select(e => e.Entity)
+ .ToList();
+ var trackedIds = templates.Select(t => t.Id).ToHashSet();
+ templates.AddRange(library.Where(t => !trackedIds.Contains(t.Id)));
+ return templates;
+ }
+
+ ///
+ /// Review 2026-10-21 04/P4 + 08/P1: the per-session view of the TARGET template
+ /// library. One import used to call the full tracked GetAllTemplatesAsync
+ /// graph (attributes, alarms, scripts WITH bodies, compositions, native sources)
+ /// up to nine times, two of them inside per-template loops. Each projection here
+ /// is read at most once per instance of this class — one per
+ /// PreviewAsync, one per apply attempt — and every consumer that needs only
+ /// ids, names, edges or member lock flags reads it instead.
+ ///
+ private sealed class TemplateLibrary
+ {
+ private readonly ITemplateEngineRepository _repo;
+ private IReadOnlyList? _analysis;
+ private IReadOnlyList? _stubs;
+ private IReadOnlyDictionary? _idByName;
+ private IReadOnlyDictionary? _nameById;
+
+ public TemplateLibrary(ITemplateEngineRepository repo) => _repo = repo;
+
+ /// Untracked templates with members, edges and lock flags, but no script bodies.
+ public async Task> GetAnalysisAsync(CancellationToken ct)
+ {
+ _analysis ??= await _repo.GetAllTemplatesForAnalysisAsync(ct).ConfigureAwait(false);
+ return _analysis;
+ }
+
+ /// Target template name → id (first wins on a duplicate name).
+ public async Task> GetIdByNameAsync(CancellationToken ct)
+ {
+ if (_idByName is null)
+ {
+ var pairs = await GetPairsAsync(ct).ConfigureAwait(false);
+ _idByName = pairs
+ .GroupBy(p => p.Name, StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => g.First().Id, StringComparer.Ordinal);
+ }
+ return _idByName;
+ }
+
+ /// Target template id → name.
+ public async Task> GetNameByIdAsync(CancellationToken ct)
+ {
+ if (_nameById is null)
+ {
+ var pairs = await GetPairsAsync(ct).ConfigureAwait(false);
+ _nameById = pairs.ToDictionary(p => p.Id, p => p.Name);
+ }
+ return _nameById;
+ }
+
+ // Id/name pairs from whichever projection is already in hand: the analysis
+ // load when a caller has made it, else the lighter stub projection.
+ private async Task> GetPairsAsync(CancellationToken ct)
+ {
+ if (_analysis is not null)
+ {
+ return _analysis.Select(t => (t.Id, t.Name)).ToList();
+ }
+ _stubs ??= await _repo.GetTemplateStubsAsync(ct).ConfigureAwait(false);
+ return _stubs.Select(t => (t.Id, t.Name)).ToList();
+ }
+ }
+
private async Task ApplySharedScriptsAsync(
IReadOnlyList dtos,
Dictionary<(string, string), ImportResolution> map,
@@ -4380,17 +4583,39 @@ public sealed class BundleImporter : IBundleImporter
Dictionary<(string, string), ImportResolution> resolutionMap,
Dictionary siteBySourceIdentifier,
ResolvedConnectionMaps connectionMaps,
+ TemplateLibrary library,
string user,
ImportSummary summary,
CancellationToken ct)
{
if (content.Instances.Count == 0) return;
- // Build a target-template name→id map once (just-imported templates were
- // flushed before this pass; pre-existing target templates count too).
- var templateIdByName = (await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false))
- .GroupBy(t => t.Name, StringComparer.Ordinal)
- .ToDictionary(g => g.Key, g => g.First().Id, StringComparer.Ordinal);
+ // Build a target-template name→id map once: the pre-write library snapshot
+ // for pre-existing templates, overlaid with the tracked (Local) templates —
+ // which include everything this apply added or renamed, flushed (so their ids
+ // are real) before this pass (review 2026-10-21 04/P4).
+ var templateIdByName = new Dictionary(
+ await library.GetIdByNameAsync(ct).ConfigureAwait(false), StringComparer.Ordinal);
+ foreach (var local in _dbContext.Templates.Local)
+ {
+ templateIdByName[local.Name] = local.Id;
+ }
+
+ // One batched, tracked, hydrated read of every existing target instance this
+ // pass may touch — the bundle's names plus every Rename target — instead of a
+ // GetInstanceByUniqueNameAsync per instance (04/P4).
+ var lookupNames = new HashSet(StringComparer.Ordinal);
+ foreach (var dto in content.Instances)
+ {
+ var r = ResolveOrDefault(resolutionMap, "Instance", dto.UniqueName);
+ if (r.Action == ResolutionAction.Skip) continue;
+ lookupNames.Add(dto.UniqueName);
+ if (r.Action == ResolutionAction.Rename && !string.IsNullOrEmpty(r.RenameTo)) lookupNames.Add(r.RenameTo);
+ }
+ var existingByName = lookupNames.Count == 0
+ ? new Dictionary(StringComparer.Ordinal)
+ : (await _templateRepo.GetInstancesByUniqueNamesAsync(lookupNames, ct).ConfigureAwait(false))
+ .ToDictionary(i => i.UniqueName, i => i, StringComparer.Ordinal);
// Memoise area resolution per (siteId, areaName) so two instances under the
// same area don't each create a duplicate row.
@@ -4431,7 +4656,7 @@ public sealed class BundleImporter : IBundleImporter
case ResolutionAction.Rename:
{
var name = resolution.RenameTo ?? dto.UniqueName;
- if (await _templateRepo.GetInstanceByUniqueNameAsync(name, ct).ConfigureAwait(false) is not null)
+ if (existingByName.ContainsKey(name))
throw new InvalidOperationException($"An instance named '{name}' already exists. Choose a different rename target.");
var inst = BuildInstance(
dto, name, templateId, targetSite, areaId, connectionMaps);
@@ -4444,8 +4669,7 @@ public sealed class BundleImporter : IBundleImporter
}
case ResolutionAction.Overwrite:
{
- var existing = await _templateRepo
- .GetInstanceByUniqueNameAsync(dto.UniqueName, ct).ConfigureAwait(false);
+ existingByName.TryGetValue(dto.UniqueName, out var existing);
if (existing is null)
{
// Overwrite chosen but no target row — treat as Add (the
@@ -4716,6 +4940,7 @@ public sealed class BundleImporter : IBundleImporter
BundleContentDto content,
Dictionary<(string, string), ImportResolution> resolutionMap,
BundleNameMap nameMap,
+ TemplateLibrary library,
CancellationToken ct)
{
if (content.Instances.Count == 0 && content.DataConnections.Count == 0 && content.Sites.Count == 0)
@@ -4819,7 +5044,7 @@ public sealed class BundleImporter : IBundleImporter
bundleTemplateNames.Add(resolution.RenameTo);
}
var targetTemplateNames = new HashSet(
- (await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false)).Select(t => t.Name),
+ (await library.GetIdByNameAsync(ct).ConfigureAwait(false)).Keys,
StringComparer.Ordinal);
foreach (var inst in content.Instances)
@@ -4869,6 +5094,7 @@ public sealed class BundleImporter : IBundleImporter
private async Task<(IReadOnlyList Errors, IReadOnlyList Warnings)> RunSemanticValidationAsync(
BundleContentDto content,
Dictionary<(string, string), ImportResolution> resolutionMap,
+ TemplateLibrary library,
CancellationToken ct)
{
var errors = new List();
@@ -4913,7 +5139,7 @@ public sealed class BundleImporter : IBundleImporter
// N4: advisory warnings for instance overrides that target a LOCKED template
// member. Best-effort, non-blocking (rides ImportResult.Warnings); the row is
// still written (bundle fidelity) — the flattener is the behavioural authority.
- var lockScanTemplates = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
+ var lockScanTemplates = await library.GetAnalysisAsync(ct).ConfigureAwait(false);
foreach (var (_, message) in CollectLockedOverrideWarnings(content, resolutionMap, lockScanTemplates))
{
warnings.Add(message);
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs
index fcabceb6..13ff037d 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs
@@ -1505,6 +1505,111 @@ public sealed class BundleImporterApplyTests : IDisposable
Assert.DoesNotContain(valveInstanceId, result.StaleInstanceIds);
}
+ ///
+ /// Review 2026-10-21 04/C2: overwriting a feature MODULE changes the flattened
+ /// config of every template that composes it, so a deployed instance of the
+ /// composing template — which has no direct relationship to the overwritten
+ /// template id — must be reported stale.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task ApplyAsync_overwriting_a_composed_module_marks_instances_of_the_composing_template_stale()
+ {
+ await SeedSiteAsync();
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ var module = new Template("Motor") { Description = "module" };
+ module.Attributes.Add(new TemplateAttribute("Speed") { DataType = DataType.Float, Value = "1.0" });
+ module.Attributes.Add(new TemplateAttribute("Torque") { DataType = DataType.Float, Value = "2.0" });
+ ctx.Templates.Add(module);
+ await ctx.SaveChangesAsync();
+ var station = new Template("Station") { Description = "composer" };
+ station.Compositions.Add(new TemplateComposition("motor1") { ComposedTemplateId = module.Id });
+ ctx.Templates.Add(station);
+ await ctx.SaveChangesAsync();
+ }
+ var sessionId = await ExportAndLoadAsync();
+
+ // Drop Torque from the target module, then deploy a Station instance against
+ // that shape; the Overwrite restores Torque inside the composed slot.
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ var module = await ctx.Templates.Include(x => x.Attributes).SingleAsync(x => x.Name == "Motor");
+ var torque = module.Attributes.Single(a => a.Name == "Torque");
+ module.Attributes.Remove(torque);
+ ctx.TemplateAttributes.Remove(torque);
+ await ctx.SaveChangesAsync();
+ }
+ var stationInstanceId = await SeedDeployedInstanceWithRealSnapshotAsync("Station", "Station-Deployed");
+
+ ImportResult result;
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var importer = scope.ServiceProvider.GetRequiredService();
+ result = await importer.ApplyAsync(sessionId,
+ new List
+ {
+ new("Template", "Motor", ResolutionAction.Overwrite, null),
+ new("Template", "Station", ResolutionAction.Skip, null),
+ },
+ user: "bob");
+ }
+
+ Assert.Contains(stationInstanceId, result.StaleInstanceIds);
+ }
+
+ ///
+ /// Review 2026-10-21 04/C2: overwriting a BASE template changes every derived
+ /// template's flattened config, so a deployed instance of a derived template must
+ /// be reported stale even though no instance uses the base directly.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task ApplyAsync_overwriting_a_base_template_marks_instances_of_derived_templates_stale()
+ {
+ await SeedSiteAsync();
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ var baseT = new Template("Receiver") { Description = "base" };
+ baseT.Attributes.Add(new TemplateAttribute("Flow") { DataType = DataType.Float, Value = "1.0" });
+ baseT.Attributes.Add(new TemplateAttribute("Pressure") { DataType = DataType.Float, Value = "2.0" });
+ ctx.Templates.Add(baseT);
+ await ctx.SaveChangesAsync();
+ ctx.Templates.Add(new Template("Reactor") { Description = "derived", ParentTemplateId = baseT.Id });
+ await ctx.SaveChangesAsync();
+ }
+ var sessionId = await ExportAndLoadAsync();
+
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ var baseT = await ctx.Templates.Include(x => x.Attributes).SingleAsync(x => x.Name == "Receiver");
+ var pressure = baseT.Attributes.Single(a => a.Name == "Pressure");
+ baseT.Attributes.Remove(pressure);
+ ctx.TemplateAttributes.Remove(pressure);
+ await ctx.SaveChangesAsync();
+ }
+ var reactorInstanceId = await SeedDeployedInstanceWithRealSnapshotAsync("Reactor", "Reactor-Deployed");
+
+ ImportResult result;
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var importer = scope.ServiceProvider.GetRequiredService();
+ result = await importer.ApplyAsync(sessionId,
+ new List
+ {
+ new("Template", "Receiver", ResolutionAction.Overwrite, null),
+ new("Template", "Reactor", ResolutionAction.Skip, null),
+ },
+ user: "bob");
+ }
+
+ Assert.Contains(reactorInstanceId, result.StaleInstanceIds);
+ }
+
/// Verifies that a freshly imported NotDeployed instance is never reported as stale.
/// A task that represents the asynchronous operation.
[Fact]
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/ImportRoundTripBudgetTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/ImportRoundTripBudgetTests.cs
new file mode 100644
index 00000000..3f6aacf8
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/ImportRoundTripBudgetTests.cs
@@ -0,0 +1,209 @@
+using System.Collections.Concurrent;
+using System.Reflection;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
+using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
+using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
+using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
+
+namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
+
+///
+/// Review 2026-10-21 04/P4 + 08/P1: one import used to load the WHOLE template
+/// library (attributes, alarms, scripts with bodies, compositions — tracked) up to
+/// nine times, two of them inside per-template loops, and to look every bundled
+/// instance up one query at a time. These tests count repository calls through a
+/// recording proxy so the per-session budget is pinned.
+///
+public sealed class ImportRoundTripBudgetTests : IDisposable
+{
+ private readonly ServiceProvider _provider;
+ private readonly ConcurrentDictionary _calls = new(StringComparer.Ordinal);
+
+ /// Initializes the test class with a counting template repository over an in-memory database.
+ public ImportRoundTripBudgetTests()
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton(
+ new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider());
+ services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build());
+
+ var dbName = $"ImportRoundTripBudgetTests_{Guid.NewGuid()}";
+ services.AddDbContext(opts => opts
+ .UseInMemoryDatabase(dbName)
+ .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
+
+ services.AddSingleton();
+ services.AddScoped(sp => CountingProxy.Wrap(
+ new TemplateEngineRepository(
+ sp.GetRequiredService(),
+ sp.GetRequiredService()),
+ _calls));
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddTransport();
+
+ _provider = services.BuildServiceProvider();
+ }
+
+ /// Releases the resources used by this instance.
+ public void Dispose() => _provider.Dispose();
+
+ private int Calls(string method) => _calls.TryGetValue(method, out var n) ? n : 0;
+
+ ///
+ /// Seeds a library of templates (a base, a module, and several composing /
+ /// derived templates) plus a site with instances, exports the site closure with
+ /// its templates, and returns the loaded session.
+ ///
+ private async Task SeedExportAndLoadAsync(int instanceCount)
+ {
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ var baseT = new Template("Base");
+ baseT.Attributes.Add(new TemplateAttribute("Flow") { DataType = DataType.Float, Value = "1.0" });
+ var module = new Template("Motor");
+ module.Attributes.Add(new TemplateAttribute("Speed") { DataType = DataType.Float, Value = "1.0" });
+ ctx.Templates.AddRange(baseT, module);
+ await ctx.SaveChangesAsync();
+
+ var pump = new Template("Pump") { ParentTemplateId = baseT.Id };
+ pump.Compositions.Add(new TemplateComposition("motor1") { ComposedTemplateId = module.Id });
+ ctx.Templates.Add(pump);
+ // Unrelated library templates: the import must not pay for them per item.
+ for (var i = 0; i < 5; i++) ctx.Templates.Add(new Template($"Other{i}"));
+ var site = new Site("Plant 1", "plant-1") { GrpcNodeAAddress = "http://plant-1-a:8083" };
+ ctx.Sites.Add(site);
+ await ctx.SaveChangesAsync();
+
+ for (var i = 0; i < instanceCount; i++)
+ {
+ ctx.Instances.Add(new Instance($"Pump-{i:00}") { TemplateId = pump.Id, SiteId = site.Id });
+ }
+ await ctx.SaveChangesAsync();
+ }
+
+ Stream bundle;
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ var siteIds = await ctx.Sites.Select(s => s.Id).ToListAsync();
+ bundle = await scope.ServiceProvider.GetRequiredService().ExportAsync(
+ new ExportSelection(
+ TemplateIds: Array.Empty(),
+ SharedScriptIds: Array.Empty(),
+ ExternalSystemIds: Array.Empty(),
+ DatabaseConnectionIds: Array.Empty(),
+ NotificationListIds: Array.Empty(),
+ SmtpConfigurationIds: Array.Empty(),
+ ApiMethodIds: Array.Empty(),
+ IncludeDependencies: true,
+ SiteIds: siteIds),
+ user: "alice", sourceEnvironment: "dev", passphrase: null,
+ cancellationToken: CancellationToken.None);
+ }
+
+ await using var loadScope = _provider.CreateAsyncScope();
+ return (await loadScope.ServiceProvider.GetRequiredService()
+ .LoadAsync(bundle, passphrase: null)).SessionId;
+ }
+
+ ///
+ /// Verifies that preview + apply of a bundle carrying templates and instances
+ /// never loads the full template graph, loads the library projection at most once
+ /// per phase, and looks instances up in batches rather than per instance.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task Preview_and_apply_load_the_template_library_once_per_phase_and_batch_instance_lookups()
+ {
+ var sessionId = await SeedExportAndLoadAsync(instanceCount: 6);
+ _calls.Clear();
+
+ ImportPreview preview;
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ preview = await scope.ServiceProvider.GetRequiredService().PreviewAsync(sessionId);
+ }
+ Assert.DoesNotContain(preview.Items, i => i.Kind == ConflictKind.Blocker);
+
+ Assert.Equal(0, Calls(nameof(ITemplateEngineRepository.GetAllTemplatesAsync)));
+ Assert.Equal(0, Calls(nameof(ITemplateEngineRepository.GetInstanceByUniqueNameAsync)));
+ Assert.Equal(0, Calls(nameof(ITemplateEngineRepository.GetTemplateByIdAsync)));
+ Assert.True(
+ Calls(nameof(ITemplateEngineRepository.GetAllTemplatesForAnalysisAsync))
+ + Calls(nameof(ITemplateEngineRepository.GetTemplateStubsAsync)) <= 2,
+ "preview should load the library projection at most once per shape");
+ _calls.Clear();
+
+ var resolutions = preview.Items
+ .Where(i => i.EntityType is "Template" or "Instance" or "Site")
+ .Select(i => new ImportResolution(i.EntityType, i.Name, ResolutionAction.Overwrite, null))
+ .ToList();
+ ImportResult result;
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ result = await scope.ServiceProvider.GetRequiredService()
+ .ApplyAsync(sessionId, resolutions, user: "bob");
+ }
+
+ Assert.True(result.Overwritten >= 6 + 3, $"expected the templates and instances to be overwritten, got {result.Overwritten}");
+ Assert.Equal(0, Calls(nameof(ITemplateEngineRepository.GetAllTemplatesAsync)));
+ Assert.Equal(0, Calls(nameof(ITemplateEngineRepository.GetInstanceByUniqueNameAsync)));
+ Assert.True(Calls(nameof(ITemplateEngineRepository.GetAllTemplatesForAnalysisAsync)) <= 1,
+ "apply should load the analysis library at most once");
+ Assert.True(Calls(nameof(ITemplateEngineRepository.GetInstancesByUniqueNamesAsync)) <= 1,
+ "apply should look the bundle's instances up in one batch");
+ }
+
+ /// Records every interface call by method name, then forwards it to the real implementation.
+ /// The proxied interface.
+ public class CountingProxy : DispatchProxy where T : class
+ {
+ private T _inner = null!;
+ private ConcurrentDictionary _calls = null!;
+
+ /// Creates a counting proxy over .
+ /// The real implementation.
+ /// The shared call counter.
+ /// The proxy.
+ public static T Wrap(T inner, ConcurrentDictionary calls)
+ {
+ var proxy = Create>();
+ var self = (CountingProxy)(object)proxy;
+ self._inner = inner;
+ self._calls = calls;
+ return proxy;
+ }
+
+ ///
+ protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
+ {
+ _calls.AddOrUpdate(targetMethod!.Name, 1, (_, n) => n + 1);
+ try
+ {
+ return targetMethod.Invoke(_inner, args);
+ }
+ catch (TargetInvocationException ex) when (ex.InnerException is not null)
+ {
+ throw ex.InnerException;
+ }
+ }
+ }
+}