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))
+ target.CallsTo(nameof(ITemplateEngineRepository.GetBaseTemplatesWithChildrenAsync)),
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);
}
}