fix(transport): import reads the template library once per session, batches instance lookups, and expands stale instances to derived/composing templates over one flatten session (review 2026-10-21 04/P4, 08/P1, 04/C2)

This commit is contained in:
Joseph Doherty
2026-10-22 09:36:34 -04:00
parent 39025b28fa
commit 7e08a1dbfb
8 changed files with 723 additions and 109 deletions
@@ -262,6 +262,15 @@ public interface ITemplateEngineRepository
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that resolves to the matching instance, or <see langword="null"/> if not found.</returns>
Task<Instance?> GetInstanceByUniqueNameAsync(string uniqueName, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves every instance whose unique name is in <paramref name="uniqueNames"/>, with the
/// same four child collections <see cref="GetInstanceByUniqueNameAsync"/> includes, in one
/// round trip (tracked). Unknown names are simply absent from the result.
/// </summary>
/// <param name="uniqueNames">The unique instance names to look up; null/empty/duplicate entries are ignored.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that resolves to the matching instances.</returns>
Task<IReadOnlyList<Instance>> GetInstancesByUniqueNamesAsync(IEnumerable<string> uniqueNames, CancellationToken cancellationToken = default);
/// <summary>Adds a new instance.</summary>
/// <param name="instance">The instance to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
@@ -29,4 +29,14 @@ public interface IStaleInstanceProbe
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The current revision hash (e.g. <c>sha256:...</c>), or <c>null</c>.</returns>
Task<string?> GetCurrentRevisionHashAsync(int instanceId, CancellationToken cancellationToken = default);
/// <summary>
/// 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).
/// </summary>
/// <returns>A probe to use for the batch.</returns>
IStaleInstanceProbe BeginBatch() => this;
}
@@ -476,6 +476,30 @@ public class TemplateEngineRepository : ITemplateEngineRepository
.FirstOrDefaultAsync(i => i.UniqueName == uniqueName, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Instance>> GetInstancesByUniqueNamesAsync(
IEnumerable<string> 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<Instance>();
var distinct = uniqueNames
.Where(n => !string.IsNullOrEmpty(n))
.Distinct(StringComparer.Ordinal)
.ToArray();
if (distinct.Length == 0) return Array.Empty<Instance>();
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);
}
/// <inheritdoc />
public async Task AddInstanceAsync(Instance instance, CancellationToken cancellationToken = default)
{
@@ -24,4 +24,33 @@ public sealed class StaleInstanceProbe : IStaleInstanceProbe
instanceId, _flatteningPipeline.CreateSession(), cancellationToken).ConfigureAwait(false);
return result.IsSuccess ? result.Value : null;
}
/// <inheritdoc />
/// <remarks>
/// The batch probe shares ONE <see cref="FlattenSession"/> 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.
/// </remarks>
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<string?> 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;
}
}
@@ -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<ImportPreviewItem>();
// ---- 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<Commons.Entities.ExternalSystems.ExternalSystemMethod>? 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<string, Instance>(StringComparer.Ordinal)
: (await _templateRepo
.GetInstancesByUniqueNamesAsync(content.Instances.Select(i => i.UniqueName), ct)
.ConfigureAwait(false))
.ToDictionary(i => i.UniqueName, i => i, StringComparer.Ordinal);
Dictionary<int, string>? siteIdentifierById = null;
var areaNameByIdBySite = new Dictionary<int, IReadOnlyDictionary<int, string>>();
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<IReadOnlyList<ImportPreviewItem>> DetectBlockersAsync(
BundleContentDto content,
Func<string, Task<IReadOnlyList<DataConnection>>> resolveTargetConnections,
TemplateLibrary library,
IReadOnlyList<SharedScript> allSharedScripts,
IReadOnlyList<ExternalSystemDefinition> allExternalSystems,
CancellationToken ct)
{
var blockers = new List<ImportPreviewItem>();
@@ -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<string>(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<string>(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
/// <summary>
/// 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 <c>DeployedConfigSnapshot</c>
/// 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
/// <c>ParentTemplateId</c> and templates composing it through
/// <c>TemplateComposition.ComposedTemplateId</c>, 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 <see cref="InstanceState.Enabled"/>/<see cref="InstanceState.Disabled"/>
/// with a snapshot), recomputes their current hash through
/// <see cref="IStaleInstanceProbe"/> (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
/// <see cref="IStaleInstanceProbe"/> (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.
/// <para>
/// Freshly-IMPORTED instances are excluded structurally: they enter the target
/// <see cref="InstanceState.NotDeployed"/> (no snapshot), so they're "new", not
@@ -2032,6 +2080,7 @@ public sealed class BundleImporter : IBundleImporter
private async Task<IReadOnlyList<int>> 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<int>();
foreach (var dto in content.Templates)
@@ -2065,57 +2113,118 @@ public sealed class BundleImporter : IBundleImporter
return Array.Empty<int>();
}
var staleIds = new List<int>();
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<int>();
}
// 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<int>();
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;
}
/// <summary>
/// Review 2026-10-21 04/C2: closes <paramref name="seedTemplateIds"/> over the
/// "my flattened config includes yours" relation — a template depends on its
/// base (<c>ParentTemplateId</c>) and on every module it composes — so the result
/// is every template whose flattened output can change when a seed changes.
/// </summary>
/// <param name="seedTemplateIds">The templates whose content changed.</param>
/// <param name="graph">The post-apply template graph.</param>
/// <returns>The seeds plus every transitive dependent.</returns>
internal static HashSet<int> ExpandToDependentTemplates(
IEnumerable<int> seedTemplateIds, IReadOnlyList<Template> graph)
{
var dependentsOf = new Dictionary<int, List<int>>();
void AddEdge(int dependency, int dependent)
{
if (!dependentsOf.TryGetValue(dependency, out var list))
{
list = new List<int>();
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<int>(seedTemplateIds);
var queue = new Queue<int>(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;
}
/// <summary>Mutable per-apply counter struct, accumulated through every helper.</summary>
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<Template>()
: 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<TemplateDto> 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<TemplateDto> 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.
/// </summary>
/// <remarks>
/// 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 <c>GetAllTemplatesAsync</c> 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 <see cref="MergeTemplateGraph"/>: 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
/// <c>GetAllTemplatesAsync</c> 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.
/// </remarks>
private void EnsureNoTemplateGraphCycles()
/// <param name="library">The pre-write analysis snapshot of the template library.</param>
private void EnsureNoTemplateGraphCycles(IReadOnlyList<Template> library)
{
var templates = _dbContext.ChangeTracker.Entries<Template>()
.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
}
}
/// <summary>
/// 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.
/// </summary>
/// <param name="library">The pre-write analysis snapshot of the template library.</param>
/// <returns>One <see cref="Template"/> per id.</returns>
private List<Template> MergeTemplateGraph(IReadOnlyList<Template> library)
{
var templates = _dbContext.ChangeTracker.Entries<Template>()
.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;
}
/// <summary>
/// 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 <c>GetAllTemplatesAsync</c>
/// 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
/// <c>PreviewAsync</c>, one per apply attempt — and every consumer that needs only
/// ids, names, edges or member lock flags reads it instead.
/// </summary>
private sealed class TemplateLibrary
{
private readonly ITemplateEngineRepository _repo;
private IReadOnlyList<Template>? _analysis;
private IReadOnlyList<TemplateStub>? _stubs;
private IReadOnlyDictionary<string, int>? _idByName;
private IReadOnlyDictionary<int, string>? _nameById;
public TemplateLibrary(ITemplateEngineRepository repo) => _repo = repo;
/// <summary>Untracked templates with members, edges and lock flags, but no script bodies.</summary>
public async Task<IReadOnlyList<Template>> GetAnalysisAsync(CancellationToken ct)
{
_analysis ??= await _repo.GetAllTemplatesForAnalysisAsync(ct).ConfigureAwait(false);
return _analysis;
}
/// <summary>Target template name → id (first wins on a duplicate name).</summary>
public async Task<IReadOnlyDictionary<string, int>> 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;
}
/// <summary>Target template id → name.</summary>
public async Task<IReadOnlyDictionary<int, string>> 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<IReadOnlyList<(int Id, string Name)>> 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<SharedScriptDto> dtos,
Dictionary<(string, string), ImportResolution> map,
@@ -4380,17 +4583,39 @@ public sealed class BundleImporter : IBundleImporter
Dictionary<(string, string), ImportResolution> resolutionMap,
Dictionary<string, Site> 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<string, int>(
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<string>(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<string, Instance>(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<string>(
(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<string> Errors, IReadOnlyList<string> Warnings)> RunSemanticValidationAsync(
BundleContentDto content,
Dictionary<(string, string), ImportResolution> resolutionMap,
TemplateLibrary library,
CancellationToken ct)
{
var errors = new List<string>();
@@ -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);