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:
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+24
@@ -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);
|
||||
|
||||
+105
@@ -1505,6 +1505,111 @@ public sealed class BundleImporterApplyTests : IDisposable
|
||||
Assert.DoesNotContain(valveInstanceId, result.StaleInstanceIds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[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<ScadaBridgeDbContext>();
|
||||
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<ScadaBridgeDbContext>();
|
||||
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<IBundleImporter>();
|
||||
result = await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution>
|
||||
{
|
||||
new("Template", "Motor", ResolutionAction.Overwrite, null),
|
||||
new("Template", "Station", ResolutionAction.Skip, null),
|
||||
},
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
Assert.Contains(stationInstanceId, result.StaleInstanceIds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[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<ScadaBridgeDbContext>();
|
||||
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<ScadaBridgeDbContext>();
|
||||
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<IBundleImporter>();
|
||||
result = await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution>
|
||||
{
|
||||
new("Template", "Receiver", ResolutionAction.Overwrite, null),
|
||||
new("Template", "Reactor", ResolutionAction.Skip, null),
|
||||
},
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
Assert.Contains(reactorInstanceId, result.StaleInstanceIds);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a freshly imported NotDeployed instance is never reported as stale.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
|
||||
+209
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class ImportRoundTripBudgetTests : IDisposable
|
||||
{
|
||||
private readonly ServiceProvider _provider;
|
||||
private readonly ConcurrentDictionary<string, int> _calls = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Initializes the test class with a counting template repository over an in-memory database.</summary>
|
||||
public ImportRoundTripBudgetTests()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<Microsoft.AspNetCore.DataProtection.IDataProtectionProvider>(
|
||||
new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider());
|
||||
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().AddInMemoryCollection().Build());
|
||||
|
||||
var dbName = $"ImportRoundTripBudgetTests_{Guid.NewGuid()}";
|
||||
services.AddDbContext<ScadaBridgeDbContext>(opts => opts
|
||||
.UseInMemoryDatabase(dbName)
|
||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
||||
|
||||
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
|
||||
services.AddScoped<ITemplateEngineRepository>(sp => CountingProxy<ITemplateEngineRepository>.Wrap(
|
||||
new TemplateEngineRepository(
|
||||
sp.GetRequiredService<ScadaBridgeDbContext>(),
|
||||
sp.GetRequiredService<ITemplateGraphWatermark>()),
|
||||
_calls));
|
||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
|
||||
services.AddScoped<ISiteRepository, SiteRepository>();
|
||||
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
||||
services.AddScoped<IAuditService, AuditService>();
|
||||
services.AddTransport();
|
||||
|
||||
_provider = services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
/// <summary>Releases the resources used by this instance.</summary>
|
||||
public void Dispose() => _provider.Dispose();
|
||||
|
||||
private int Calls(string method) => _calls.TryGetValue(method, out var n) ? n : 0;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private async Task<Guid> SeedExportAndLoadAsync(int instanceCount)
|
||||
{
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
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<ScadaBridgeDbContext>();
|
||||
var siteIds = await ctx.Sites.Select(s => s.Id).ToListAsync();
|
||||
bundle = await scope.ServiceProvider.GetRequiredService<IBundleExporter>().ExportAsync(
|
||||
new ExportSelection(
|
||||
TemplateIds: Array.Empty<int>(),
|
||||
SharedScriptIds: Array.Empty<int>(),
|
||||
ExternalSystemIds: Array.Empty<int>(),
|
||||
DatabaseConnectionIds: Array.Empty<int>(),
|
||||
NotificationListIds: Array.Empty<int>(),
|
||||
SmtpConfigurationIds: Array.Empty<int>(),
|
||||
ApiMethodIds: Array.Empty<int>(),
|
||||
IncludeDependencies: true,
|
||||
SiteIds: siteIds),
|
||||
user: "alice", sourceEnvironment: "dev", passphrase: null,
|
||||
cancellationToken: CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var loadScope = _provider.CreateAsyncScope();
|
||||
return (await loadScope.ServiceProvider.GetRequiredService<IBundleImporter>()
|
||||
.LoadAsync(bundle, passphrase: null)).SessionId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[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<IBundleImporter>().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<IBundleImporter>()
|
||||
.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");
|
||||
}
|
||||
|
||||
/// <summary>Records every interface call by method name, then forwards it to the real implementation.</summary>
|
||||
/// <typeparam name="T">The proxied interface.</typeparam>
|
||||
public class CountingProxy<T> : DispatchProxy where T : class
|
||||
{
|
||||
private T _inner = null!;
|
||||
private ConcurrentDictionary<string, int> _calls = null!;
|
||||
|
||||
/// <summary>Creates a counting proxy over <paramref name="inner"/>.</summary>
|
||||
/// <param name="inner">The real implementation.</param>
|
||||
/// <param name="calls">The shared call counter.</param>
|
||||
/// <returns>The proxy.</returns>
|
||||
public static T Wrap(T inner, ConcurrentDictionary<string, int> calls)
|
||||
{
|
||||
var proxy = Create<T, CountingProxy<T>>();
|
||||
var self = (CountingProxy<T>)(object)proxy;
|
||||
self._inner = inner;
|
||||
self._calls = calls;
|
||||
return proxy;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user