perf(transport): make large-bundle apply linear in the template rewire passes (ScadaBridge#47)
Measured first: a scratch scaling probe (seed -> export -> load/preview/apply into a fresh target and over itself, in-memory provider, every repository call counted, DetectChanges counted via ChangeTracker.DetectingAllChanges) showed load, preview and the instance pass scale linearly (500 / 1000 / 2000 instances apply in 0.13 / 0.27 / 0.35 s), but the post-flush template rewire passes were quadratic: 100 / 400 / 800 composing, derived templates applied in 0.5 / 8.1 / 26.5 s, with DetectChanges running 5N+10 times and GetTemplateWithChildrenAsync 2N+2 times. Cause: ResolveAlarmScriptLinksAsync, ResolveCompositionEdgesAsync and ResolveInheritanceEdgesAsync found each template with _dbContext.Templates.Local.FirstOrDefault(...) - per template, and per composition edge / base lookup. Every DbSet.Local access runs a full DetectChanges over everything the import has staged. The first two passes also re-read every template's children one query at a time (a split query per call on SQL Server). Fix (no bundle-format or semantic change): - each pass indexes the tracked templates by name once (SnapshotLocalTemplatesByName; ordinal, first wins like FirstOrDefault). These passes never stage a template or rename one, so the index stays exact for the pass; - the alarm and composition passes read their templates' children in one batched GetTemplatesWithChildrenAsync, matched back by the stub's id; - the alarm pass only loads templates whose bundled alarms name an on-trigger script (the only ones it can rewire). Result on the same probe: 100 / 400 / 800 templates apply in 0.13 / 0.62 / 0.64 s; DetectChanges is a constant 9; per-template children reads 0, batched reads 1 (add) / 2 (overwrite). Evidence: ImportApplyScalingTests (Transport.IntegrationTests) exports 5 and 40 composing, derived templates with alarm -> script links, imports each into an empty target (Add) and again (Overwrite), and asserts the imported inheritance / composition / alarm-script links, zero per-template children reads, at most 3 batched reads, and an identical DetectChanges count for 5 and 40 templates. Fails on the old importer (12 per-template reads), and with a per-DTO Local snapshot reintroduced in one pass (DetectChanges 51 vs 16). Transport unit (198) and integration (138) suites pass. Not changed: preview's per-name lookups for database connections, notification lists and API methods stay per-name - moving them to an in-memory ordinal match could change name matching under a case-insensitive SQL collation. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
+213
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>DbSet.Local</c> scan — each one a full
|
||||
/// <c>DetectChanges</c> 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 <c>DetectChanges</c> passes and batched
|
||||
/// children reads, on both the Add and the Overwrite path, without changing the result.
|
||||
/// </summary>
|
||||
public sealed class ImportApplyScalingTests
|
||||
{
|
||||
private sealed class Env : IDisposable
|
||||
{
|
||||
public Env()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<Microsoft.AspNetCore.DataProtection.IDataProtectionProvider>(
|
||||
new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider());
|
||||
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().AddInMemoryCollection().Build());
|
||||
var dbName = $"ImportApplyScalingTests_{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();
|
||||
}
|
||||
|
||||
public ServiceProvider Provider { get; }
|
||||
|
||||
public ConcurrentDictionary<string, int> 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);
|
||||
|
||||
/// <summary>
|
||||
/// Seeds <paramref name="count"/> 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.
|
||||
/// </summary>
|
||||
private static async Task<byte[]> ExportLibraryAsync(int count)
|
||||
{
|
||||
using var source = new Env();
|
||||
await using (var scope = source.Provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
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<Template>();
|
||||
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<ScadaBridgeDbContext>()
|
||||
.Templates.Select(t => t.Id).ToListAsync();
|
||||
var stream = await exportScope.ServiceProvider.GetRequiredService<IBundleExporter>().ExportAsync(
|
||||
new ExportSelection(ids, Array.Empty<int>(), Array.Empty<int>(), Array.Empty<int>(),
|
||||
Array.Empty<int>(), Array.Empty<int>(), Array.Empty<int>(), IncludeDependencies: true),
|
||||
user: "alice", sourceEnvironment: "dev", passphrase: null, cancellationToken: CancellationToken.None);
|
||||
using var ms = new MemoryStream();
|
||||
await stream.CopyToAsync(ms);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Loads, previews and applies <paramref name="bundle"/> into <paramref name="target"/>.</summary>
|
||||
private static async Task<ApplyCost> ImportAsync(Env target, byte[] bundle)
|
||||
{
|
||||
Guid sessionId;
|
||||
await using (var scope = target.Provider.CreateAsyncScope())
|
||||
sessionId = (await scope.ServiceProvider.GetRequiredService<IBundleImporter>()
|
||||
.LoadAsync(new MemoryStream(bundle), passphrase: null)).SessionId;
|
||||
|
||||
ImportPreview preview;
|
||||
await using (var scope = target.Provider.CreateAsyncScope())
|
||||
preview = await scope.ServiceProvider.GetRequiredService<IBundleImporter>().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<ScadaBridgeDbContext>()
|
||||
.ChangeTracker.DetectingAllChanges += (_, _) => Interlocked.Increment(ref detectChanges);
|
||||
var result = await applyScope.ServiceProvider.GetRequiredService<IBundleImporter>()
|
||||
.ApplyAsync(sessionId, resolutions, user: "bob");
|
||||
|
||||
return new ApplyCost(
|
||||
detectChanges,
|
||||
target.CallsTo(nameof(ITemplateEngineRepository.GetTemplateWithChildrenAsync)),
|
||||
target.CallsTo(nameof(ITemplateEngineRepository.GetTemplatesWithChildrenAsync)),
|
||||
result);
|
||||
}
|
||||
|
||||
/// <summary>Asserts the imported graph: every pump derives from Base, composes Module, and links its alarm.</summary>
|
||||
private static async Task AssertGraphImportedAsync(Env target, int count)
|
||||
{
|
||||
await using var scope = target.Provider.CreateAsyncScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Apply_cost_does_not_grow_with_the_bundle_on_add_and_overwrite()
|
||||
{
|
||||
var costs = new Dictionary<int, (ApplyCost Add, ApplyCost Overwrite)>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user