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
@@ -1,6 +1,7 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
@@ -23,6 +24,7 @@ public class FlatteningPipeline : IFlatteningPipeline
private readonly ValidationService _validationService;
private readonly RevisionHashService _revisionHashService;
private readonly ISharedSchemaRepository _sharedSchemaRepo;
private readonly ITemplateGraphWatermark _watermark;
/// <summary>Initializes a new <see cref="FlatteningPipeline"/> with the required template engine and site repositories and services.</summary>
/// <param name="templateRepo">Repository for loading templates and instance data.</param>
@@ -35,13 +37,19 @@ public class FlatteningPipeline : IFlatteningPipeline
/// look up <c>lib:Name</c> library references so a dangling reference in any validated
/// script schema becomes a deploy-blocking error.
/// </param>
/// <param name="watermark">
/// Process-wide template version watermark. Supplies the per-template version
/// used as the <see cref="FlattenSession"/> cache-key discriminator, so a
/// memoised chain is dropped the moment any of its templates is edited.
/// </param>
public FlatteningPipeline(
ITemplateEngineRepository templateRepo,
ISiteRepository siteRepo,
FlatteningService flatteningService,
ValidationService validationService,
RevisionHashService revisionHashService,
ISharedSchemaRepository sharedSchemaRepo)
ISharedSchemaRepository sharedSchemaRepo,
ITemplateGraphWatermark watermark)
{
_templateRepo = templateRepo;
_siteRepo = siteRepo;
@@ -49,21 +57,35 @@ public class FlatteningPipeline : IFlatteningPipeline
_validationService = validationService;
_revisionHashService = revisionHashService;
_sharedSchemaRepo = sharedSchemaRepo;
_watermark = watermark;
}
/// <inheritdoc />
public FlattenSession CreateSession() => new(_watermark);
/// <inheritdoc />
public async Task<Result<FlatteningPipelineResult>> FlattenAndValidateAsync(
int instanceId,
CancellationToken cancellationToken = default,
bool validateScripts = true)
bool validateScripts = true,
FlattenSession? session = null)
{
// A caller that supplies no session gets a private one. That still pays
// off inside a single flatten: the composed-chain walk below reaches the
// same template through several composing parents, and the memo collapses
// those to one load each.
session ??= CreateSession();
// Load instance with full graph
var instance = await _templateRepo.GetInstanceByIdAsync(instanceId, cancellationToken);
if (instance == null)
return Result<FlatteningPipelineResult>.Failure($"Instance with ID {instanceId} not found.");
Task<Commons.Entities.Templates.Template?> LoadTemplate(int id) =>
_templateRepo.GetTemplateWithChildrenAsync(id, cancellationToken);
// Build template chain
var templateChain = await BuildTemplateChainAsync(instance.TemplateId, cancellationToken);
var templateChain = await session.GetChainAsync(instance.TemplateId, LoadTemplate);
if (templateChain.Count == 0)
return Result<FlatteningPipelineResult>.Failure("Template chain is empty.");
@@ -84,7 +106,9 @@ public class FlatteningPipeline : IFlatteningPipeline
{
if (!processedTemplateIds.Add(template.Id)) continue;
var compositions = await _templateRepo.GetCompositionsByTemplateIdAsync(template.Id, cancellationToken);
var compositions = await session.GetCompositionsAsync(
template.Id,
id => _templateRepo.GetCompositionsByTemplateIdAsync(id, cancellationToken));
if (compositions.Count == 0) continue;
compositionMap[template.Id] = compositions;
@@ -92,7 +116,7 @@ public class FlatteningPipeline : IFlatteningPipeline
{
if (composedChains.ContainsKey(comp.ComposedTemplateId)) continue;
var composedChain = await BuildTemplateChainAsync(comp.ComposedTemplateId, cancellationToken);
var composedChain = await session.GetChainAsync(comp.ComposedTemplateId, LoadTemplate);
composedChains[comp.ComposedTemplateId] = composedChain;
pendingChains.Enqueue(composedChain);
}
@@ -100,7 +124,9 @@ public class FlatteningPipeline : IFlatteningPipeline
}
// Load data connections for the site
var dataConnections = await LoadDataConnectionsAsync(instance.SiteId, cancellationToken);
var dataConnections = await session.GetDataConnectionsAsync(
instance.SiteId,
siteId => LoadDataConnectionsAsync(siteId, cancellationToken));
// Flatten
var flattenResult = _flatteningService.Flatten(
@@ -111,15 +137,20 @@ public class FlatteningPipeline : IFlatteningPipeline
var config = flattenResult.Value;
// Load shared scripts for semantic validation
var sharedScriptEntities = await _templateRepo.GetAllSharedScriptsAsync(cancellationToken);
var resolvedSharedScripts = sharedScriptEntities.Select(s => new ResolvedScript
// Load shared scripts for semantic validation. Session-global: identical
// for every instance in a batch, so hoisted to one query per session
// (the ArtifactDeploymentService global-hoist shape).
var resolvedSharedScripts = await session.GetSharedScriptsAsync(async () =>
{
CanonicalName = s.Name,
Code = s.Code,
ParameterDefinitions = s.ParameterDefinitions,
ReturnDefinition = s.ReturnDefinition
}).ToList();
var sharedScriptEntities = await _templateRepo.GetAllSharedScriptsAsync(cancellationToken);
return (IReadOnlyList<ResolvedScript>)sharedScriptEntities.Select(s => new ResolvedScript
{
CanonicalName = s.Name,
Code = s.Code,
ParameterDefinitions = s.ParameterDefinitions,
ReturnDefinition = s.ReturnDefinition
}).ToList();
});
// Compute the alarm-capable connection-name set so the semantic validator
// can gate native-alarm-source bindings. "Alarm-capable" matches the DCL
@@ -149,8 +180,13 @@ public class FlatteningPipeline : IFlatteningPipeline
// pre-loaded once into a name→JSON map here (avoiding sync-over-async) and the
// seam is a pure in-memory lookup. An unresolved {"$ref":"lib:Name"} in any
// validated script schema then becomes a deploy-blocking SchemaReference error.
var sharedSchemas = await _sharedSchemaRepo.ListAsync(cancellationToken);
var schemaLibrary = sharedSchemas.ToDictionary(s => s.Name, s => s.SchemaJson, StringComparer.Ordinal);
// Session-global, same as the shared scripts above.
var schemaLibrary = await session.GetSchemaLibraryAsync(async () =>
{
var sharedSchemas = await _sharedSchemaRepo.ListAsync(cancellationToken);
return (IReadOnlyDictionary<string, string>)sharedSchemas
.ToDictionary(s => s.Name, s => s.SchemaJson, StringComparer.Ordinal);
});
Func<string, string?> resolveSchemaRef = name => schemaLibrary.GetValueOrDefault(name);
// Validate. This is the deploy-gating path, so connection-binding completeness
@@ -180,24 +216,6 @@ public class FlatteningPipeline : IFlatteningPipeline
new FlatteningPipelineResult(config, hash, validation));
}
private async Task<IReadOnlyList<Commons.Entities.Templates.Template>> BuildTemplateChainAsync(
int templateId,
CancellationToken cancellationToken)
{
var chain = new List<Commons.Entities.Templates.Template>();
var currentId = (int?)templateId;
while (currentId.HasValue)
{
var template = await _templateRepo.GetTemplateWithChildrenAsync(currentId.Value, cancellationToken);
if (template == null) break;
chain.Add(template);
currentId = template.ParentTemplateId;
}
return chain;
}
private async Task<IReadOnlyDictionary<int, DataConnection>> LoadDataConnectionsAsync(
int siteId,
CancellationToken cancellationToken)