perf(deploy): flatten-session caching, bulk DeploySiteAsync, paged management queries

This commit is contained in:
Joseph Doherty
2026-08-14 21:14:22 -04:00
parent ee193cd2bb
commit 48b3c40a7f
59 changed files with 3051 additions and 231 deletions
@@ -3,20 +3,32 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
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.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
public class TemplateEngineRepository : ITemplateEngineRepository
{
private readonly ScadaBridgeDbContext _context;
private readonly ITemplateGraphWatermark _watermark;
/// <summary>
/// Initializes a new instance of the TemplateEngineRepository class.
/// </summary>
/// <param name="context">The database context used to access template and instance data.</param>
public TemplateEngineRepository(ScadaBridgeDbContext context)
/// <param name="watermark">
/// Process-wide template/instance version watermark. This repository is the
/// single unit-of-work through which EVERY template-graph mutation commits —
/// <c>TemplateService</c>, the <c>ManagementActor</c> native-alarm-source
/// handlers, and the Transport bundle importer all funnel through the same
/// <see cref="SaveChangesAsync"/> — so bumping here (rather than in each
/// caller) is the only placement that cannot be bypassed.
/// </param>
public TemplateEngineRepository(ScadaBridgeDbContext context, ITemplateGraphWatermark watermark)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_watermark = watermark ?? throw new ArgumentNullException(nameof(watermark));
}
// Template
@@ -78,6 +90,95 @@ public class TemplateEngineRepository : ITemplateEngineRepository
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
/// <remarks>
/// Two deliberate departures from <see cref="GetAllTemplatesAsync"/>:
/// <list type="number">
/// <item>
/// <c>AsNoTracking</c> — the analysis walks (cycle detection, collision
/// detection, canonical-name resolution) never write through the loaded
/// graph, so paying for a change-tracker snapshot of every attribute,
/// alarm, script and composition in the database is pure waste.
/// </item>
/// <item>
/// <c>TemplateScript.Code</c> is projected away (left empty). Script
/// bodies are by far the largest column in the graph and NONE of the
/// analysis consumers read them — <c>CycleDetector</c> reads only ids and
/// edges, <c>CollisionDetector</c> and <c>TemplateResolver</c> read only
/// member names and lock flags.
/// </item>
/// </list>
/// Callers that DO need bodies or tracked entities — the inheritance
/// reconciler and the flattener — must keep using
/// <see cref="GetAllTemplatesAsync"/> / <see cref="GetTemplateWithChildrenAsync"/>.
/// </remarks>
public async Task<IReadOnlyList<Template>> GetAllTemplatesForAnalysisAsync(CancellationToken cancellationToken = default)
{
return await _context.Templates
.AsNoTracking()
.Select(t => new Template(t.Name)
{
Id = t.Id,
Description = t.Description,
ParentTemplateId = t.ParentTemplateId,
FolderId = t.FolderId,
IsDerived = t.IsDerived,
OwnerCompositionId = t.OwnerCompositionId,
Attributes = t.Attributes.ToList(),
Alarms = t.Alarms.ToList(),
NativeAlarmSources = t.NativeAlarmSources.ToList(),
Compositions = t.Compositions.ToList(),
Scripts = t.Scripts
.Select(s => new TemplateScript(s.Name, string.Empty)
{
Id = s.Id,
TemplateId = s.TemplateId,
IsLocked = s.IsLocked,
TriggerType = s.TriggerType,
TriggerConfiguration = s.TriggerConfiguration,
ParameterDefinitions = s.ParameterDefinitions,
ReturnDefinition = s.ReturnDefinition,
MinTimeBetweenRuns = s.MinTimeBetweenRuns,
ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds,
IsInherited = s.IsInherited,
LockedInDerived = s.LockedInDerived
})
.ToList()
})
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TemplateSummary>> GetTemplateSummariesAsync(
int skip,
int? take,
CancellationToken cancellationToken = default)
{
var query = _context.Templates
.AsNoTracking()
.OrderBy(t => t.Id)
.Skip(Math.Max(0, skip));
if (take is > 0)
query = query.Take(Math.Min(take.Value, TemplateSummary.MaxPageSize));
return await query
.Select(t => new TemplateSummary(
t.Id,
t.Name,
t.Description,
t.ParentTemplateId,
t.FolderId,
t.IsDerived,
t.OwnerCompositionId,
t.Attributes.Count,
t.Alarms.Count,
t.Scripts.Count,
t.Compositions.Count,
t.NativeAlarmSources.Count))
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Template>> GetTemplatesComposingAsync(int composedTemplateId, CancellationToken cancellationToken = default)
{
@@ -659,8 +760,129 @@ public class TemplateEngineRepository : ITemplateEngineRepository
}
/// <inheritdoc />
/// <remarks>
/// Inspects the change tracker BEFORE committing (afterwards every entry is
/// <see cref="EntityState.Unchanged"/> and the attribution is lost) and bumps
/// the <see cref="ITemplateGraphWatermark"/> for each affected template /
/// instance. The bump is applied only AFTER a successful commit, so a failed
/// save does not invalidate caches for a change that never landed.
/// </remarks>
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
return await _context.SaveChangesAsync(cancellationToken);
var pending = CollectWatermarkBumps();
var written = await _context.SaveChangesAsync(cancellationToken);
ApplyWatermarkBumps(pending);
return written;
}
/// <summary>
/// Snapshot of the watermark bumps implied by the current change-tracker
/// contents. Captured pre-commit, applied post-commit.
/// </summary>
private readonly record struct WatermarkBumps(
HashSet<int> Templates,
HashSet<int> StructuralTemplates,
HashSet<int> Instances,
bool Unattributed);
/// <summary>
/// Walks the change tracker and maps every added/modified/deleted
/// template-graph or instance-graph entry back to the template (or instance)
/// whose flattened output it can affect.
///
/// <para>
/// A change is treated as STRUCTURAL — invalidating cached chain membership,
/// not just chain contents — when it adds or removes a <see cref="Template"/>
/// row, changes a <see cref="Template.ParentTemplateId"/>, or touches a
/// <see cref="TemplateComposition"/> row. Those are exactly the edges the
/// flattener walks to build a chain.
/// </para>
///
/// <para>
/// An entry whose owning id cannot be resolved (a child row deleted before
/// its FK was materialised, or an entity type added later that this switch
/// does not know) sets <c>Unattributed</c>, which conservatively invalidates
/// every cached chain rather than silently letting a stale entry survive.
/// </para>
/// </summary>
private WatermarkBumps CollectWatermarkBumps()
{
var templates = new HashSet<int>();
var structural = new HashSet<int>();
var instances = new HashSet<int>();
var unattributed = false;
foreach (var entry in _context.ChangeTracker.Entries())
{
if (entry.State is not (EntityState.Added or EntityState.Modified or EntityState.Deleted))
continue;
switch (entry.Entity)
{
case Template t:
templates.Add(t.Id);
// Add/Delete changes chain membership; a re-parent changes it too.
if (entry.State != EntityState.Modified
|| entry.Property(nameof(Template.ParentTemplateId)).IsModified)
{
structural.Add(t.Id);
}
break;
case TemplateAttribute a:
templates.Add(a.TemplateId);
break;
case TemplateAlarm al:
templates.Add(al.TemplateId);
break;
case TemplateScript s:
templates.Add(s.TemplateId);
break;
case TemplateNativeAlarmSource ns:
templates.Add(ns.TemplateId);
break;
case TemplateComposition c:
templates.Add(c.TemplateId);
structural.Add(c.TemplateId);
break;
case Instance i:
instances.Add(i.Id);
break;
case InstanceAttributeOverride ao:
instances.Add(ao.InstanceId);
break;
case InstanceAlarmOverride alo:
instances.Add(alo.InstanceId);
break;
case InstanceNativeAlarmSourceOverride nso:
instances.Add(nso.InstanceId);
break;
case InstanceConnectionBinding cb:
instances.Add(cb.InstanceId);
break;
case SharedScript:
// Shared scripts are a session-global input to validation, not
// owned by any one template — invalidate everything.
unattributed = true;
break;
default:
// Areas, folders and anything else do not feed the flattener.
break;
}
}
return new WatermarkBumps(templates, structural, instances, unattributed);
}
/// <summary>Applies a previously collected <see cref="WatermarkBumps"/> snapshot.</summary>
private void ApplyWatermarkBumps(WatermarkBumps bumps)
{
if (bumps.Unattributed)
_watermark.BumpAll();
foreach (var templateId in bumps.Templates)
_watermark.BumpTemplate(templateId, bumps.StructuralTemplates.Contains(templateId));
foreach (var instanceId in bumps.Instances)
_watermark.BumpInstance(instanceId);
}
}