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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user