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
@@ -218,7 +218,7 @@ public class TemplateService
// only invariant the move can break is two templates sharing a
// (FolderId, Name) at the destination, which the design's
// naming-collisions-are-design-time-errors rule forbids.
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
var collision = allTemplates.FirstOrDefault(t =>
t.Id != templateId &&
t.FolderId == newFolderId &&
@@ -300,7 +300,7 @@ public class TemplateService
attribute.TemplateId = templateId;
// If inheriting, validate not trying to add a member that would collide
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
var testTemplate = CloneTemplateWithNewAttribute(template, attribute);
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
if (collisions.Count > 0)
@@ -352,7 +352,7 @@ public class TemplateService
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
if (template?.ParentTemplateId != null)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
var parentMember = TemplateResolver.FindMemberByCanonicalName(
existing.Name, template.ParentTemplateId.Value, allTemplates);
if (parentMember != null && parentMember.IsLocked)
@@ -436,7 +436,7 @@ public class TemplateService
var template = await _repository.GetTemplateByIdAsync(attribute.TemplateId, cancellationToken);
if (template?.ParentTemplateId != null)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
var parentMember = TemplateResolver.FindMemberByCanonicalName(
attribute.Name, template.ParentTemplateId.Value, allTemplates);
if (parentMember != null)
@@ -533,7 +533,7 @@ public class TemplateService
alarm.TemplateId = templateId;
// Check collisions
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
var testTemplate = CloneTemplateWithNewAlarm(template, alarm);
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
if (collisions.Count > 0)
@@ -583,7 +583,7 @@ public class TemplateService
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
if (template?.ParentTemplateId != null)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
var parentMember = TemplateResolver.FindMemberByCanonicalName(
existing.Name, template.ParentTemplateId.Value, allTemplates);
if (parentMember != null && parentMember.IsLocked)
@@ -656,7 +656,7 @@ public class TemplateService
var template = await _repository.GetTemplateByIdAsync(alarm.TemplateId, cancellationToken);
if (template?.ParentTemplateId != null)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
var parentMember = TemplateResolver.FindMemberByCanonicalName(
alarm.Name, template.ParentTemplateId.Value, allTemplates);
if (parentMember != null)
@@ -703,7 +703,7 @@ public class TemplateService
script.TemplateId = templateId;
// Check collisions
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
var testTemplate = CloneTemplateWithNewScript(template, script);
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
if (collisions.Count > 0)
@@ -750,7 +750,7 @@ public class TemplateService
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
if (template?.ParentTemplateId != null)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
var parentMember = TemplateResolver.FindMemberByCanonicalName(
existing.Name, template.ParentTemplateId.Value, allTemplates);
if (parentMember != null && parentMember.IsLocked)
@@ -827,7 +827,7 @@ public class TemplateService
var template = await _repository.GetTemplateByIdAsync(script.TemplateId, cancellationToken);
if (template?.ParentTemplateId != null)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
var parentMember = TemplateResolver.FindMemberByCanonicalName(
script.Name, template.ParentTemplateId.Value, allTemplates);
if (parentMember != null)
@@ -888,7 +888,7 @@ public class TemplateService
// Acyclicity is checked against the base, not the to-be-created derived template —
// the derived inherits from the base, so a base→base cycle is the meaningful check.
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
var cycleError = CycleDetector.DetectCompositionCycle(templateId, composedTemplateId, allTemplates);
if (cycleError != null)
return Result<TemplateComposition>.Failure(cycleError);
@@ -1226,19 +1226,34 @@ public class TemplateService
/// Propagates a base member change to every derived descendant by reconciling
/// their inherited rows. Called automatically after a base member is added,
/// updated, or deleted. The base template itself is NOT reconciled — it is the
/// author of the change. A no-op (and a single cheap query) when the template
/// has no descendants, which is the common case.
/// author of the change. A no-op when the template has no descendants, which is
/// the common case.
/// </summary>
/// <param name="templateId">The template whose member set changed.</param>
/// <param name="user">Username for the audit row.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <param name="loadedTemplates">
/// The full, TRACKED template graph if the caller already has one. Reconciling
/// writes through these entities and compares script bodies, so this must be
/// the tracked <c>GetAllTemplatesAsync</c> shape — never the no-tracking,
/// body-free analysis projection. When <see langword="null"/> the graph is
/// loaded here.
///
/// <para>
/// Passing it matters because every member Add/Update/Delete on
/// <see cref="TemplateService"/> used to load the whole graph once for its own
/// checks and then again inside this method. Handing the graph over collapses
/// that to one load per operation.
/// </para>
/// </param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task ReconcileDescendantsAsync(
int templateId,
string user,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
IReadOnlyList<Template>? loadedTemplates = null)
{
var all = await _repository.GetAllTemplatesAsync(cancellationToken);
var all = loadedTemplates ?? await _repository.GetAllTemplatesAsync(cancellationToken);
var descendants = GetDescendantIdsBreadthFirst(templateId, all);
if (descendants.Count == 0)
return;
@@ -1591,7 +1606,7 @@ public class TemplateService
int templateId,
CancellationToken cancellationToken = default)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
return TemplateResolver.ResolveAllMembers(templateId, allTemplates);
}
@@ -1616,7 +1631,7 @@ public class TemplateService
if (template == null)
return Result<bool>.Failure($"Template with ID {templateId} not found.");
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
var members = TemplateResolver.ResolveAllMembers(templateId, allTemplates);
var member = members.FirstOrDefault(m => m.CanonicalName == canonicalName);
@@ -1647,7 +1662,7 @@ public class TemplateService
if (template == null)
return new[] { $"Template with ID {templateId} not found." };
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
return CollisionDetector.DetectCollisions(template, allTemplates);
}
@@ -1669,7 +1684,7 @@ public class TemplateService
int? proposedComposedTemplateId,
CancellationToken cancellationToken = default)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
if (proposedParentId.HasValue)
{
@@ -1698,7 +1713,7 @@ public class TemplateService
private async Task<string?> ValidateCollisionsAsync(Template template, CancellationToken cancellationToken)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
var collisions = CollisionDetector.DetectCollisions(template, allTemplates);
if (collisions.Count > 0)
return string.Join(" ", collisions);
@@ -30,25 +30,53 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
/// </para>
///
/// <para>
/// Bounded at <see cref="MaxEntries"/> entries; on overflow the cache is simply
/// cleared (the verdict is cheap to recompute, so a coarse reset is safe and avoids
/// eviction bookkeeping). <see cref="Hits"/>/<see cref="Count"/>/<see cref="Clear"/>
/// are exposed for tests and diagnostics.
/// <b>Eviction is segmented, never wholesale.</b> Overflow used to
/// <c>Clear()</c> the whole cache, which reads as harmless ("the verdict is cheap
/// to recompute") but is not: a recompute is a fresh Roslyn compile, and every
/// script compile loads an assembly through a NON-COLLECTIBLE
/// <c>InteractiveAssemblyLoader</c>. That leak is the entire reason this cache
/// exists, so dropping every hot entry at the 4096th distinct script re-opens it
/// for the whole working set at once. Instead the cache runs two generations: new
/// verdicts land in <c>hot</c>, a hit in <c>cold</c> is promoted back into
/// <c>hot</c>, and on overflow <c>hot</c> becomes the new <c>cold</c> and the old
/// <c>cold</c> — the half nothing has touched for a full generation — is the only
/// thing dropped. Anything actively in use is therefore retained across an
/// eviction, and the cache still cannot grow past two generations.
/// </para>
/// </summary>
public static class ScriptCompileVerdictCache
{
/// <summary>Upper bound on cached entries; the cache is cleared wholesale on overflow.</summary>
private const int MaxEntries = 4096;
/// <summary>
/// Upper bound on entries in the hot generation. The cache holds at most
/// <c>2 × SegmentCapacity</c> entries in total (hot + cold), preserving the
/// previous 4096-entry ceiling.
/// </summary>
private const int SegmentCapacity = 2048;
private static readonly ConcurrentDictionary<string, (bool Ok, string? Error)> Cache = new();
/// <summary>
/// Guards the generation ROTATION only. Reads and single-entry writes stay on
/// the lock-free <see cref="ConcurrentDictionary{TKey,TValue}"/> paths; the
/// lock exists so two threads overflowing at once cannot rotate twice and
/// discard a generation that was only one insert old.
/// </summary>
private static readonly object RotateGate = new();
private static ConcurrentDictionary<string, (bool Ok, string? Error)> _hot = new();
private static ConcurrentDictionary<string, (bool Ok, string? Error)> _cold = new();
private static long _hits;
private static long _evictions;
/// <summary>Number of cache hits observed since the last <see cref="Clear"/>.</summary>
public static long Hits => Interlocked.Read(ref _hits);
/// <summary>Current number of cached verdicts.</summary>
public static int Count => Cache.Count;
/// <summary>
/// Number of generation rotations performed. One rotation drops at most
/// <see cref="SegmentCapacity"/> untouched entries. Exposed for diagnostics.
/// </summary>
public static long Evictions => Interlocked.Read(ref _evictions);
/// <summary>Current number of cached verdicts across both generations.</summary>
public static int Count => _hot.Count + _cold.Count;
/// <summary>
/// Returns the cached verdict for the pair (<paramref name="surface"/>,
@@ -68,26 +96,67 @@ public static class ScriptCompileVerdictCache
{
var key = surface + ":" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(code)));
if (Cache.TryGetValue(key, out var verdict))
// Snapshot the generations once: a concurrent rotation between the two
// lookups would otherwise let an entry slip through both.
var hot = _hot;
var cold = _cold;
if (hot.TryGetValue(key, out var verdict))
{
Interlocked.Increment(ref _hits);
return verdict;
}
if (cold.TryGetValue(key, out verdict))
{
// Promote: the entry is in active use, so it must survive the next
// rotation. Writing to the CURRENT hot generation (re-read, in case a
// rotation happened since the snapshot) is what makes this an LRU
// rather than a fixed-lifetime cache.
Interlocked.Increment(ref _hits);
_hot[key] = verdict;
return verdict;
}
verdict = factory();
// Coarse bound: on overflow drop everything rather than track evictions.
if (Cache.Count >= MaxEntries)
Cache.Clear();
Cache[key] = verdict;
Store(key, verdict);
return verdict;
}
/// <summary>Clears all cached verdicts and resets the hit counter.</summary>
/// <summary>
/// Inserts a freshly computed verdict, rotating the generations first when the
/// hot segment is full.
/// </summary>
private static void Store(string key, (bool Ok, string? Error) verdict)
{
if (_hot.Count >= SegmentCapacity)
{
lock (RotateGate)
{
// Re-check inside the lock: a racing thread may already have
// rotated, in which case rotating again would discard a
// generation that is one insert old.
if (_hot.Count >= SegmentCapacity)
{
_cold = _hot;
_hot = new ConcurrentDictionary<string, (bool Ok, string? Error)>();
Interlocked.Increment(ref _evictions);
}
}
}
_hot[key] = verdict;
}
/// <summary>Clears all cached verdicts and resets the counters. Test/diagnostic use only.</summary>
public static void Clear()
{
Cache.Clear();
lock (RotateGate)
{
_hot = new ConcurrentDictionary<string, (bool Ok, string? Error)>();
_cold = new ConcurrentDictionary<string, (bool Ok, string? Error)>();
}
Interlocked.Exchange(ref _hits, 0);
Interlocked.Exchange(ref _evictions, 0);
}
}