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
@@ -0,0 +1,54 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
/// <summary>
/// Row-shaped projection of a <c>DeploymentRecord</c> for list surfaces (CLI
/// <c>deploy list</c> / <c>instance</c> history, the Central UI deployment-status
/// page).
///
/// <para>
/// Exists so a deployment listing can be paged and filtered in the DATABASE.
/// Deployment records are insert-only — one row per deploy attempt, retained for
/// the configured retention window — so the previous "load the whole table, then
/// filter in memory" read path degraded with the age of the system rather than
/// with the size of the requested page.
/// </para>
///
/// <para>
/// The <c>RowVersion</c> optimistic-concurrency token is deliberately absent: this
/// is a READ projection, and a summary must never be mistaken for something that
/// can be written back. Mutating paths keep loading the tracked entity.
/// </para>
///
/// <para>
/// Message-contract evolution rule: additive-only. New fields go on the end with a
/// default.
/// </para>
/// </summary>
/// <param name="Id">Deployment record row id.</param>
/// <param name="DeploymentId">The logical deployment id (GUID, "N" format).</param>
/// <param name="InstanceId">Instance the deployment targeted.</param>
/// <param name="Status">Terminal or in-flight deployment status.</param>
/// <param name="RevisionHash">Revision hash of the deployed configuration.</param>
/// <param name="DeployedBy">User who initiated the deployment.</param>
/// <param name="DeployedAt">When the deployment was initiated.</param>
/// <param name="CompletedAt">When the deployment reached a terminal status, if it has.</param>
/// <param name="ErrorMessage">Failure detail when the deployment did not succeed.</param>
public record DeploymentRecordSummary(
int Id,
string DeploymentId,
int InstanceId,
DeploymentStatus Status,
string? RevisionHash,
string DeployedBy,
DateTimeOffset DeployedAt,
DateTimeOffset? CompletedAt,
string? ErrorMessage)
{
/// <summary>
/// Hard ceiling applied to a requested page size, mirroring the in-memory
/// <c>Page</c> clamp the management actor already applies elsewhere.
/// </summary>
public const int MaxPageSize = 1000;
}
@@ -0,0 +1,72 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
/// <summary>
/// In-memory <see cref="ITemplateGraphWatermark"/>. Registered as a singleton so
/// the writer (the configuration-database repository's unit-of-work commit) and
/// the readers (the flatten-session cache, the staleness fast path) observe the
/// same counters within a process.
///
/// <para>
/// Lives in Commons rather than a component project because both the
/// ConfigurationDatabase writer and the DeploymentManager readers need it and
/// neither references the other. It is a pure in-memory primitive with no
/// infrastructure dependency, so it does not violate the "implementations live
/// with their component" convention the way a repository would.
/// </para>
///
/// <para>
/// All counters are <see cref="Interlocked"/>-updated and the per-id maps are
/// <see cref="ConcurrentDictionary{TKey,TValue}"/>, so concurrent management
/// commands (which each run on their own DI scope) can bump safely. Reads are
/// deliberately unsynchronised relative to each other: a reader that observes a
/// half-applied batch of bumps sees a CHANGED value for at least one member,
/// which invalidates rather than falsely validates a cache entry.
/// </para>
/// </summary>
public sealed class TemplateGraphWatermark : ITemplateGraphWatermark
{
private readonly ConcurrentDictionary<int, long> _templateVersions = new();
private readonly ConcurrentDictionary<int, long> _instanceVersions = new();
private long _global;
private long _structure;
/// <inheritdoc />
public long Global => Interlocked.Read(ref _global);
/// <inheritdoc />
public long StructureVersion => Interlocked.Read(ref _structure);
/// <inheritdoc />
public long GetTemplateVersion(int templateId) =>
_templateVersions.TryGetValue(templateId, out var v) ? v : 0L;
/// <inheritdoc />
public long GetInstanceVersion(int instanceId) =>
_instanceVersions.TryGetValue(instanceId, out var v) ? v : 0L;
/// <inheritdoc />
public void BumpTemplate(int templateId, bool structural = false)
{
_templateVersions.AddOrUpdate(templateId, 1L, static (_, current) => current + 1);
Interlocked.Increment(ref _global);
if (structural)
Interlocked.Increment(ref _structure);
}
/// <inheritdoc />
public void BumpInstance(int instanceId)
{
_instanceVersions.AddOrUpdate(instanceId, 1L, static (_, current) => current + 1);
Interlocked.Increment(ref _global);
}
/// <inheritdoc />
public void BumpAll()
{
Interlocked.Increment(ref _global);
Interlocked.Increment(ref _structure);
}
}
@@ -0,0 +1,53 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
/// <summary>
/// Row-shaped projection of a template for list surfaces (CLI <c>template list</c>,
/// the Central UI template tree, the <c>ListTemplates</c> management command).
///
/// <para>
/// Exists so a template listing can be paged in the DATABASE rather than by
/// materialising every template's full child graph — attributes, alarms, script
/// bodies, compositions and native alarm sources — and then discarding all but
/// one page of it in memory. Child collections are reduced to counts, which is
/// what every list surface actually renders.
/// </para>
///
/// <para>
/// Message-contract evolution rule: this record is additive-only. New fields go
/// on the end with a default so an older peer deserialising a newer payload is
/// unaffected.
/// </para>
/// </summary>
/// <param name="Id">Template id.</param>
/// <param name="Name">Template name.</param>
/// <param name="Description">Optional description.</param>
/// <param name="ParentTemplateId">Parent template id when this template inherits.</param>
/// <param name="FolderId">Containing folder id.</param>
/// <param name="IsDerived">True when the template was auto-derived to back a composition slot.</param>
/// <param name="OwnerCompositionId">Owning composition row for a derived template.</param>
/// <param name="AttributeCount">Number of directly declared attributes.</param>
/// <param name="AlarmCount">Number of directly declared alarms.</param>
/// <param name="ScriptCount">Number of directly declared scripts.</param>
/// <param name="CompositionCount">Number of composition slots.</param>
/// <param name="NativeAlarmSourceCount">Number of native alarm source bindings.</param>
public record TemplateSummary(
int Id,
string Name,
string? Description,
int? ParentTemplateId,
int? FolderId,
bool IsDerived,
int? OwnerCompositionId,
int AttributeCount,
int AlarmCount,
int ScriptCount,
int CompositionCount,
int NativeAlarmSourceCount)
{
/// <summary>
/// Hard ceiling applied to a requested page size, mirroring the existing
/// in-memory <c>Page</c> clamp in the management actor so DB-side paging
/// cannot be used to pull an unbounded result set.
/// </summary>
public const int MaxPageSize = 1000;
}