From 04ac4c0491ed0752bd85cfc2989896e570bf1324 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 09:24:31 -0400 Subject: [PATCH] fix(transport): export reads instance children once, site connections once per site, composed templates in batches (review 2026-10-21 04/P5) --- docs/requirements/Component-Transport.md | 2 + .../Export/DependencyResolver.cs | 145 ++++++++++++------ .../Export/DependencyResolverTests.cs | 117 +++++++++++++- 3 files changed, 214 insertions(+), 50 deletions(-) diff --git a/docs/requirements/Component-Transport.md b/docs/requirements/Component-Transport.md index b1ef7fdc..f86f9513 100644 --- a/docs/requirements/Component-Transport.md +++ b/docs/requirements/Component-Transport.md @@ -159,6 +159,8 @@ A **Sites & Instances** section (T18) adds a flat list of sites, each row expand The user can toggle "include all dependencies" off (with a warning that the bundle may produce an invalid import). +**Resolver round trips (review 2026-10-21 04/P5).** Instances are read with their override/binding collections in the same query (`GetInstanceByIdAsync` / the template repository's `GetInstancesBySiteIdAsync` both `Include` them — there is no per-collection reload); a site's data connections are read once per export and shared between the site seed and the per-instance closure; and missing templates (instance templates, then each level of the composition closure) are fetched in one batch through `GetTemplatesWithChildrenAsync` keyed by an id→name map from `GetTemplateStubsAsync` — a lone missing template still uses the by-id accessor. Exporting one 500-instance site is a few dozen queries rather than ~5 000. + **Step 3 — Encryption.** Passphrase and confirm. Strength meter. Explicit warning lists every secret field being encrypted. An "Export without encryption" path is logged with the `UnencryptedBundleExport` audit flag. **Step 4 — Download.** Generated filename pattern: `scadabundle-{sourceEnv}-{yyyy-MM-dd-HHmmss}.scadabundle`. SHA-256 displayed for out-of-band verification. diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Export/DependencyResolver.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Export/DependencyResolver.cs index b7c7374e..ce8e0ef0 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Export/DependencyResolver.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Export/DependencyResolver.cs @@ -136,6 +136,10 @@ public sealed class DependencyResolver var dataConnections = new Dictionary(); var instances = new Dictionary(); + // Review 2026-10-21 04/P5: one GetDataConnectionsBySiteIdAsync per site per + // export — the site seed and the per-instance closure below share this cache. + var siteConnectionCache = new Dictionary>(); + // Selecting a SITE pulls its data connections and all of its instances. The // instance's overrides/bindings are loaded eagerly so the serializer can read // them off the entity navigation collections. @@ -145,16 +149,17 @@ public sealed class DependencyResolver if (site is null) continue; sites[site.Id] = site; - foreach (var conn in await _siteRepository.GetDataConnectionsBySiteIdAsync(site.Id, ct).ConfigureAwait(false)) + foreach (var conn in await GetSiteConnectionsAsync(site.Id, siteConnectionCache, ct).ConfigureAwait(false)) { dataConnections[conn.Id] = conn; } - foreach (var siteInstance in await _siteRepository.GetInstancesBySiteIdAsync(site.Id, ct).ConfigureAwait(false)) + // The template repository's GetInstancesBySiteIdAsync Includes the four + // override/binding collections (the site repository's does not), so the + // site-loaded entity is used as-is — no per-instance reload (04/P5). + foreach (var siteInstance in await _templates.GetInstancesBySiteIdAsync(site.Id, ct).ConfigureAwait(false)) { - if (instances.ContainsKey(siteInstance.Id)) continue; - var loaded = await LoadInstanceWithChildrenAsync(siteInstance.Id, ct).ConfigureAwait(false); - if (loaded is not null) instances[loaded.Id] = loaded; + instances.TryAdd(siteInstance.Id, siteInstance); } } @@ -164,7 +169,9 @@ public sealed class DependencyResolver foreach (var instanceId in selection.InstanceIds.Distinct()) { if (instances.ContainsKey(instanceId)) continue; - var loaded = await LoadInstanceWithChildrenAsync(instanceId, ct).ConfigureAwait(false); + // GetInstanceByIdAsync Includes the four override/binding collections, so + // no per-collection reload is needed (04/P5). + var loaded = await _templates.GetInstanceByIdAsync(instanceId, ct).ConfigureAwait(false); if (loaded is not null) instances[loaded.Id] = loaded; } @@ -175,9 +182,11 @@ public sealed class DependencyResolver // binds, and (by feeding instance.TemplateId into the template dictionary) // its template — so the template/shared-script/external-system closure below // expands transitively over instance templates too. - await ExpandSiteInstanceClosureAsync(instances, sites, dataConnections, templates, ct).ConfigureAwait(false); + var templateIdByName = new TemplateNameLookup(_templates); + await ExpandSiteInstanceClosureAsync( + instances, sites, dataConnections, templates, siteConnectionCache, templateIdByName, ct).ConfigureAwait(false); - await ExpandTemplateClosureAsync(templates, ct).ConfigureAwait(false); + await ExpandTemplateClosureAsync(templates, templateIdByName, ct).ConfigureAwait(false); await ExpandSharedScriptsFromTemplatesAsync(templates, sharedScripts, ct).ConfigureAwait(false); await ExpandSharedScriptsFromApiMethodsAsync(apiMethods, sharedScripts, ct).ConfigureAwait(false); await ExpandExternalSystemsFromTemplatesAsync(templates, externalSystems, ct).ConfigureAwait(false); @@ -272,25 +281,71 @@ public sealed class DependencyResolver }; } - // ---- Instance child loading ---- - // Loads an instance and attaches its overrides/bindings onto the entity's own - // navigation collections, so the serializer reads children off the entity (the - // same shape central-config entities use). Returns null if the instance is gone. - private async Task LoadInstanceWithChildrenAsync(int instanceId, CancellationToken ct) + // ---- Batched loads (review 2026-10-21 04/P5) ---- + + // Reads a site's data connections at most once per ResolveAsync call. + private async Task> GetSiteConnectionsAsync( + int siteId, Dictionary> cache, CancellationToken ct) { - var instance = await _templates.GetInstanceByIdAsync(instanceId, ct).ConfigureAwait(false); - if (instance is null) return null; + if (!cache.TryGetValue(siteId, out var connections)) + { + connections = await _siteRepository.GetDataConnectionsBySiteIdAsync(siteId, ct).ConfigureAwait(false); + cache[siteId] = connections; + } + return connections; + } - var attributeOverrides = await _templates.GetOverridesByInstanceIdAsync(instance.Id, ct).ConfigureAwait(false); - var alarmOverrides = await _templates.GetAlarmOverridesByInstanceIdAsync(instance.Id, ct).ConfigureAwait(false); - var nativeAlarmOverrides = await _templates.GetNativeAlarmSourceOverridesByInstanceIdAsync(instance.Id, ct).ConfigureAwait(false); - var bindings = await _templates.GetBindingsByInstanceIdAsync(instance.Id, ct).ConfigureAwait(false); + // Lazily-loaded id → name map over the whole template library (a body-free + // projection), so a set of template ids can be fetched with children through the + // by-name bulk accessor in one round trip instead of one by-id load each. + private sealed class TemplateNameLookup + { + private readonly ITemplateEngineRepository _repo; + private Dictionary? _nameById; - instance.AttributeOverrides = attributeOverrides.ToList(); - instance.AlarmOverrides = alarmOverrides.ToList(); - instance.NativeAlarmSourceOverrides = nativeAlarmOverrides.ToList(); - instance.ConnectionBindings = bindings.ToList(); - return instance; + public TemplateNameLookup(ITemplateEngineRepository repo) => _repo = repo; + + public async Task> GetAsync(CancellationToken ct) + { + if (_nameById is null) + { + var stubs = await _repo.GetTemplateStubsAsync(ct).ConfigureAwait(false); + _nameById = stubs.ToDictionary(t => t.Id, t => t.Name); + } + return _nameById; + } + } + + // Adds every template in `ids` not yet in `templates`, with children. A single + // missing id keeps the by-id accessor (no library projection needed); two or more + // go through one batched by-name load. Returns the newly added templates. + private async Task> LoadMissingTemplatesAsync( + IEnumerable ids, + Dictionary templates, + TemplateNameLookup lookup, + CancellationToken ct) + { + var missing = ids.Where(id => !templates.ContainsKey(id)).Distinct().ToList(); + var added = new List