perf(deploy): flatten-session caching, bulk DeploySiteAsync, paged management queries
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user