diff --git a/docs/requirements/Component-Transport.md b/docs/requirements/Component-Transport.md
index 579a40b4..39272d11 100644
--- a/docs/requirements/Component-Transport.md
+++ b/docs/requirements/Component-Transport.md
@@ -303,7 +303,7 @@ Site identifiers and connection names are environment-specific, so a bundle expo
**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.
+**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. Each pass indexes the change tracker's templates by name **once** (a `DbSet.Local` access runs a full `DetectChanges` over everything the import has staged, so the former per-template and per-edge scans made apply time quadratic in bundle size) and reads the children it rewires in **one** batched `GetTemplatesWithChildrenAsync`; the alarm pass loads only templates whose bundled alarms name an on-trigger script (Gitea #47 — measured on the in-memory provider, 800 composing, derived templates applied in 0.6 s instead of 26.5 s; `ImportApplyScalingTests` pins the cost as independent of bundle size).
**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).
diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs
index 3ecf8159..82726e6e 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs
@@ -3055,10 +3055,18 @@ public sealed class BundleImporter : IBundleImporter
{
if (dtos.Count == 0) return;
+ // Gitea #47: only a template whose bundled alarms name an on-trigger script
+ // has a link to rewire, so only those are loaded — and in one batched read,
+ // not one children query per template. The tracked-template name index is
+ // built once (each DbSet.Local access runs DetectChanges over the whole
+ // tracker, which made this pass quadratic in the bundle's size).
+ var localByName = SnapshotLocalTemplatesByName();
+ var targets = new List<(TemplateDto Dto, Template Stub)>();
foreach (var dto in dtos)
{
var resolution = ResolveOrDefault(resolutionMap, "Template", dto.Name);
if (resolution.Action == ResolutionAction.Skip) continue;
+ if (!dto.Alarms.Any(a => !string.IsNullOrEmpty(a.OnTriggerScriptName))) continue;
// Resolve the imported template by its post-rename name. Skip-
// resolved DTOs were already filtered out above; everything else
@@ -3066,16 +3074,19 @@ public sealed class BundleImporter : IBundleImporter
var importedName = resolution.Action == ResolutionAction.Rename
? (resolution.RenameTo ?? dto.Name)
: dto.Name;
+ if (localByName.TryGetValue(importedName, out var stub))
+ targets.Add((dto, stub));
+ }
+ if (targets.Count == 0) return;
- // Use the children-loaded variant so the alarm + script collections
- // are populated — the unfiltered GetAllTemplatesAsync returns the
- // tracked entities EF assigned ids to during the flush, but the
- // navigation collections aren't necessarily included.
- var stub = _dbContext.Templates.Local.FirstOrDefault(t =>
- string.Equals(t.Name, importedName, StringComparison.Ordinal));
- if (stub is null) continue;
- var template = await _templateRepo.GetTemplateWithChildrenAsync(stub.Id, ct).ConfigureAwait(false);
- if (template is null) continue;
+ // Use the children-loaded read so the alarm + script collections are
+ // populated — the tracked entities EF assigned ids to during the flush
+ // don't necessarily have their navigation collections included.
+ var loadedById = await LoadTemplatesWithChildrenAsync(targets.Select(t => t.Stub), ct).ConfigureAwait(false);
+
+ foreach (var (dto, stub) in targets)
+ {
+ if (!loadedById.TryGetValue(stub.Id, out var template)) continue;
foreach (var alarmDto in dto.Alarms)
{
@@ -3142,6 +3153,12 @@ public sealed class BundleImporter : IBundleImporter
{
if (dtos.Count == 0) return;
+ // Gitea #47: one tracked-template name index for the pass (this pass stages
+ // composition rows only, never a template, so the index stays exact) and one
+ // batched children read for every parent, instead of a DbSet.Local scan
+ // (= a full DetectChanges) and a children query per template + per edge.
+ var localByName = SnapshotLocalTemplatesByName();
+ var targets = new List<(TemplateDto Dto, Template Stub)>();
foreach (var dto in dtos)
{
var resolution = ResolveOrDefault(resolutionMap, "Template", dto.Name);
@@ -3152,11 +3169,16 @@ public sealed class BundleImporter : IBundleImporter
? (resolution.RenameTo ?? dto.Name)
: dto.Name;
- var parentStub = _dbContext.Templates.Local.FirstOrDefault(t =>
- string.Equals(t.Name, importedName, StringComparison.Ordinal));
- if (parentStub is null) continue;
- var parent = await _templateRepo.GetTemplateWithChildrenAsync(parentStub.Id, ct).ConfigureAwait(false);
- if (parent is null) continue;
+ if (localByName.TryGetValue(importedName, out var parentStub))
+ targets.Add((dto, parentStub));
+ }
+ if (targets.Count == 0) return;
+
+ var loadedById = await LoadTemplatesWithChildrenAsync(targets.Select(t => t.Stub), ct).ConfigureAwait(false);
+
+ foreach (var (dto, parentStub) in targets)
+ {
+ if (!loadedById.TryGetValue(parentStub.Id, out var parent)) continue;
// Clear existing rows (Overwrite-truly-overwrites). EF Core tracks
// the deletes via the navigation; explicit DeleteAsync ensures the
@@ -3200,8 +3222,7 @@ public sealed class BundleImporter : IBundleImporter
// 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));
+ localByName.TryGetValue(compDto.ComposedTemplateName, out var composedStub);
int? composedId = composedStub?.Id;
if (composedId is null
&& (await library.GetIdByNameAsync(ct).ConfigureAwait(false))
@@ -3258,6 +3279,10 @@ public sealed class BundleImporter : IBundleImporter
{
if (dtos.Count == 0) return;
+ // Gitea #47: one tracked-template name index for the pass (it only rewrites
+ // ParentTemplateId, never a name) instead of two DbSet.Local scans per DTO.
+ var localByName = SnapshotLocalTemplatesByName();
+
foreach (var dto in dtos)
{
var resolution = ResolveOrDefault(resolutionMap, "Template", dto.Name);
@@ -3268,9 +3293,7 @@ public sealed class BundleImporter : IBundleImporter
: dto.Name;
// The just-imported/updated template is tracked in the Local set.
- var template = _dbContext.Templates.Local.FirstOrDefault(t =>
- string.Equals(t.Name, importedName, StringComparison.Ordinal));
- if (template is null) continue;
+ if (!localByName.TryGetValue(importedName, out var template)) continue;
// A root-template bundle — or an Overwrite that dropped the base —
// must clear any stale parent edge on the target row.
@@ -3289,8 +3312,7 @@ public sealed class BundleImporter : IBundleImporter
? (baseResolution.RenameTo ?? dto.BaseTemplateName)
: dto.BaseTemplateName;
- var baseStub = _dbContext.Templates.Local.FirstOrDefault(t =>
- string.Equals(t.Name, baseName, StringComparison.Ordinal));
+ localByName.TryGetValue(baseName, out var baseStub);
int? baseId = baseStub?.Id;
if (baseId is null
&& (await library.GetIdByNameAsync(ct).ConfigureAwait(false)).TryGetValue(baseName, out var libraryId))
@@ -3324,6 +3346,46 @@ public sealed class BundleImporter : IBundleImporter
}
}
+ ///
+ /// Indexes the change tracker's templates by name (ordinal; the first tracked
+ /// template wins a duplicate, matching the FirstOrDefault scans this
+ /// replaces). Gitea #47: every DbSet.Local access runs a full
+ /// DetectChanges over the tracker — every template, member and instance
+ /// the import has staged — so the rewire passes take one snapshot per pass
+ /// instead of one scan per bundled template (and per composition edge), which
+ /// made a large import's apply quadratic in its size.
+ ///
+ private Dictionary SnapshotLocalTemplatesByName()
+ {
+ var byName = new Dictionary(StringComparer.Ordinal);
+ foreach (var template in _dbContext.Templates.Local)
+ {
+ byName.TryAdd(template.Name, template);
+ }
+ return byName;
+ }
+
+ ///
+ /// Loads the given tracked templates with their child collections in one batched
+ /// read (Gitea #47 — previously one GetTemplateWithChildrenAsync per
+ /// template), keyed by id. The read is by name, so the result is keyed by id and
+ /// looked up by each stub's id: a database collation that matches names
+ /// case-insensitively can only add rows, never swap the exact one out.
+ ///
+ private async Task> LoadTemplatesWithChildrenAsync(
+ IEnumerable stubs, CancellationToken ct)
+ {
+ var loaded = await _templateRepo
+ .GetTemplatesWithChildrenAsync(stubs.Select(t => t.Name), ct)
+ .ConfigureAwait(false);
+ var byId = new Dictionary();
+ foreach (var template in loaded)
+ {
+ byId.TryAdd(template.Id, template);
+ }
+ return byId;
+ }
+
///
/// Post-rewire acyclicity guard: reads the fully-merged template graph off the
/// change tracker (staged, not-yet-saved edges included) and rejects the import
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/ImportApplyScalingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/ImportApplyScalingTests.cs
new file mode 100644
index 00000000..760b3b59
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/ImportApplyScalingTests.cs
@@ -0,0 +1,213 @@
+using System.Collections.Concurrent;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+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;
+using static ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import.ImportRoundTripBudgetTests;
+
+namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
+
+///
+/// Gitea #47 (large-bundle hardening): the apply's post-flush template rewire passes
+/// (alarm → on-trigger script links, composition edges, inheritance edges) looked every
+/// bundled template up with a DbSet.Local scan — each one a full
+/// DetectChanges over everything the import had staged — and re-read each template's
+/// children one query at a time. Apply time grew quadratically (measured on the in-memory
+/// provider: 100 / 400 / 800 composing, derived templates took 0.5 s / 8.1 s / 26.5 s;
+/// after the fix 0.13 s / 0.62 s / 0.64 s). These tests pin the cost to the bundle-size-
+/// independent shape: a constant number of DetectChanges passes and batched
+/// children reads, on both the Add and the Overwrite path, without changing the result.
+///
+public sealed class ImportApplyScalingTests
+{
+ private sealed class Env : IDisposable
+ {
+ public Env()
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton(
+ new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider());
+ services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build());
+ var dbName = $"ImportApplyScalingTests_{Guid.NewGuid()}";
+ services.AddDbContext(opts => opts
+ .UseInMemoryDatabase(dbName)
+ .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
+ services.AddSingleton();
+ services.AddScoped(sp => CountingProxy.Wrap(
+ new TemplateEngineRepository(
+ sp.GetRequiredService(),
+ sp.GetRequiredService()),
+ Calls));
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddTransport();
+ Provider = services.BuildServiceProvider();
+ }
+
+ public ServiceProvider Provider { get; }
+
+ public ConcurrentDictionary Calls { get; } = new(StringComparer.Ordinal);
+
+ public int CallsTo(string method) => Calls.TryGetValue(method, out var n) ? n : 0;
+
+ public void Dispose() => Provider.Dispose();
+ }
+
+ private sealed record ApplyCost(int DetectChanges, int PerTemplateReads, int BatchedReads, ImportResult Result);
+
+ ///
+ /// Seeds templates that derive from a base, compose a module and
+ /// carry an alarm whose on-trigger script is one of their own scripts, then exports them.
+ ///
+ private static async Task ExportLibraryAsync(int count)
+ {
+ using var source = new Env();
+ await using (var scope = source.Provider.CreateAsyncScope())
+ {
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ var module = new Template("Module");
+ module.Attributes.Add(new TemplateAttribute("Speed") { DataType = DataType.Float, Value = "1" });
+ var baseT = new Template("Base");
+ baseT.Attributes.Add(new TemplateAttribute("Flow") { DataType = DataType.Float, Value = "1" });
+ ctx.Templates.AddRange(module, baseT);
+ await ctx.SaveChangesAsync();
+
+ var templates = new List();
+ for (var i = 0; i < count; i++)
+ {
+ var t = new Template($"Pump{i:000}") { ParentTemplateId = baseT.Id };
+ t.Attributes.Add(new TemplateAttribute("Level") { DataType = DataType.Float, Value = $"{i}" });
+ t.Scripts.Add(new TemplateScript("OnHigh", "return 1;") { TriggerType = "Startup" });
+ t.Alarms.Add(new TemplateAlarm("High")
+ {
+ PriorityLevel = 500,
+ TriggerType = AlarmTriggerType.ValueMatch,
+ TriggerConfiguration = "{\"attribute\":\"Level\",\"matchValue\":\"1\"}",
+ });
+ t.Compositions.Add(new TemplateComposition("motor") { ComposedTemplateId = module.Id });
+ templates.Add(t);
+ }
+ ctx.Templates.AddRange(templates);
+ await ctx.SaveChangesAsync();
+ foreach (var t in templates)
+ t.Alarms.Single().OnTriggerScriptId = t.Scripts.Single().Id;
+ await ctx.SaveChangesAsync();
+ }
+
+ await using var exportScope = source.Provider.CreateAsyncScope();
+ var ids = await exportScope.ServiceProvider.GetRequiredService()
+ .Templates.Select(t => t.Id).ToListAsync();
+ var stream = await exportScope.ServiceProvider.GetRequiredService().ExportAsync(
+ new ExportSelection(ids, Array.Empty(), Array.Empty(), Array.Empty(),
+ Array.Empty(), Array.Empty(), Array.Empty(), IncludeDependencies: true),
+ user: "alice", sourceEnvironment: "dev", passphrase: null, cancellationToken: CancellationToken.None);
+ using var ms = new MemoryStream();
+ await stream.CopyToAsync(ms);
+ return ms.ToArray();
+ }
+
+ /// Loads, previews and applies into .
+ private static async Task ImportAsync(Env target, byte[] bundle)
+ {
+ Guid sessionId;
+ await using (var scope = target.Provider.CreateAsyncScope())
+ sessionId = (await scope.ServiceProvider.GetRequiredService()
+ .LoadAsync(new MemoryStream(bundle), passphrase: null)).SessionId;
+
+ ImportPreview preview;
+ await using (var scope = target.Provider.CreateAsyncScope())
+ preview = await scope.ServiceProvider.GetRequiredService().PreviewAsync(sessionId);
+ Assert.DoesNotContain(preview.Items, i => i.Kind == ConflictKind.Blocker);
+
+ var resolutions = preview.Items
+ .Where(i => i.Kind is ConflictKind.Modified or ConflictKind.Identical)
+ .Select(i => new ImportResolution(i.EntityType, i.Name, ResolutionAction.Overwrite, null))
+ .ToList();
+
+ target.Calls.Clear();
+ var detectChanges = 0;
+ await using var applyScope = target.Provider.CreateAsyncScope();
+ applyScope.ServiceProvider.GetRequiredService()
+ .ChangeTracker.DetectingAllChanges += (_, _) => Interlocked.Increment(ref detectChanges);
+ var result = await applyScope.ServiceProvider.GetRequiredService()
+ .ApplyAsync(sessionId, resolutions, user: "bob");
+
+ return new ApplyCost(
+ detectChanges,
+ target.CallsTo(nameof(ITemplateEngineRepository.GetTemplateWithChildrenAsync)),
+ target.CallsTo(nameof(ITemplateEngineRepository.GetTemplatesWithChildrenAsync)),
+ result);
+ }
+
+ /// Asserts the imported graph: every pump derives from Base, composes Module, and links its alarm.
+ private static async Task AssertGraphImportedAsync(Env target, int count)
+ {
+ await using var scope = target.Provider.CreateAsyncScope();
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ var baseId = await ctx.Templates.Where(t => t.Name == "Base").Select(t => t.Id).SingleAsync();
+ var moduleId = await ctx.Templates.Where(t => t.Name == "Module").Select(t => t.Id).SingleAsync();
+ var pumps = await ctx.Templates
+ .Include(t => t.Alarms).Include(t => t.Scripts).Include(t => t.Compositions)
+ .Where(t => t.Name.StartsWith("Pump"))
+ .ToListAsync();
+
+ Assert.Equal(count, pumps.Count);
+ Assert.All(pumps, p =>
+ {
+ Assert.Equal(baseId, p.ParentTemplateId);
+ Assert.Equal(moduleId, Assert.Single(p.Compositions).ComposedTemplateId);
+ Assert.Equal(p.Scripts.Single().Id, p.Alarms.Single().OnTriggerScriptId);
+ });
+ }
+
+ ///
+ /// Adding a bundle into an empty target and then overwriting it with the same bundle both run
+ /// a constant number of DetectChanges passes — the same for 5 templates as for 40 — never a
+ /// per-template children read, and at most one batched children read per rewire pass; the
+ /// imported inheritance, composition and alarm-script links are intact on both paths.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task Apply_cost_does_not_grow_with_the_bundle_on_add_and_overwrite()
+ {
+ var costs = new Dictionary();
+ foreach (var count in new[] { 5, 40 })
+ {
+ var bundle = await ExportLibraryAsync(count);
+ using var target = new Env();
+
+ var add = await ImportAsync(target, bundle);
+ Assert.Equal(count + 2, add.Result.Added);
+ await AssertGraphImportedAsync(target, count);
+
+ var overwrite = await ImportAsync(target, bundle);
+ Assert.Equal(count + 2, overwrite.Result.Overwritten);
+ await AssertGraphImportedAsync(target, count);
+
+ foreach (var cost in new[] { add, overwrite })
+ {
+ Assert.Equal(0, cost.PerTemplateReads);
+ // Overwrite's own hydrate + the alarm-link pass + the composition pass.
+ Assert.InRange(cost.BatchedReads, 1, 3);
+ }
+ costs[count] = (add, overwrite);
+ }
+
+ Assert.Equal(costs[5].Add.DetectChanges, costs[40].Add.DetectChanges);
+ Assert.Equal(costs[5].Overwrite.DetectChanges, costs[40].Overwrite.DetectChanges);
+ }
+}