perf(adminui): 5s snapshot cache in front of the script tag catalog

This commit is contained in:
Joseph Doherty
2026-08-16 04:05:07 -04:00
parent ba977dbe0d
commit 3795899d10
4 changed files with 259 additions and 8 deletions
@@ -79,11 +79,25 @@ public sealed record ScriptTagInfo(string Path, string Kind, string DataType, st
/// same RawPath collapse to one suggestion, which is correct — the string is what resolves.
/// </para>
/// <para>
/// Each call creates and disposes its own context via the pooled factory — the same pattern
/// Each call creates and disposes its own context via the factory — the same pattern
/// <c>UnsTreeService</c> uses — so the service is safe to register Scoped per Blazor circuit.
/// The factory really is pooled: <c>ServiceCollectionExtensions.AddOtOpcUaConfigDb</c> registers it
/// with <c>AddPooledDbContextFactory</c>, so a per-call create is a rent from the pool.
/// </para>
/// <para>
/// <b>The six-table projection is cached for 5 s</b> behind the singleton
/// <see cref="ScriptTagCatalogSnapshotCache"/>, shared across circuits, because Monaco fires a
/// completion request per keystroke inside a string literal. Completions and hover may therefore
/// lag a just-saved tag by up to five seconds — see that type's remarks for why the bound is safe.
/// <see cref="GetEquipmentReferenceNamesAsync"/> is deliberately NOT cached: it is two
/// equipment-scoped queries, not a fleet-wide scan.
/// </para>
/// </remarks>
public sealed class ScriptTagCatalog(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory) : IScriptTagCatalog
/// <param name="dbFactory">Pooled ConfigDb context factory.</param>
/// <param name="cache">Process-wide 5 s snapshot of the fleet-wide projection.</param>
public sealed class ScriptTagCatalog(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
ScriptTagCatalogSnapshotCache cache) : IScriptTagCatalog
{
/// <summary>Upper bound on returned suggestions — keeps the completion list responsive on large fleets.</summary>
private const int MaxResults = 200;
@@ -91,7 +105,7 @@ public sealed class ScriptTagCatalog(IDbContextFactory<OtOpcUaConfigDbContext> d
/// <inheritdoc />
public async Task<IReadOnlyList<string>> GetPathsAsync(string? filter, CancellationToken ct)
{
var entries = await BuildEntriesAsync(ct);
var entries = await cache.GetOrBuildAsync(BuildEntriesAsync, ct);
var paths = new HashSet<string>(entries.Select(e => e.Path), StringComparer.OrdinalIgnoreCase);
@@ -110,7 +124,7 @@ public sealed class ScriptTagCatalog(IDbContextFactory<OtOpcUaConfigDbContext> d
/// <inheritdoc />
public async Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
{
var entries = await BuildEntriesAsync(ct);
var entries = await cache.GetOrBuildAsync(BuildEntriesAsync, ct);
// Exact, case-SENSITIVE (Ordinal) match: the live runtime resolves a ctx.GetTag("X") literal
// via DependencyMuxActor keyed with StringComparer.Ordinal, so a case-mismatched path would
@@ -0,0 +1,86 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
/// <summary>
/// A short-TTL, process-wide snapshot of the <see cref="ScriptTagCatalog"/>'s projection, shared by
/// every Blazor circuit and every script-analysis endpoint call.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why.</b> Building the projection is SIX unfiltered table scans — <c>RawFolders</c>,
/// <c>DriverInstances</c>, <c>Devices</c>, <c>TagGroups</c>, <c>Tags</c>, <c>VirtualTags</c> — plus
/// a <c>RawPathResolver</c> pass over every tag. Monaco fires a completion request on <i>every
/// keystroke</i> inside a <c>ctx.GetTag("…")</c> string literal, and hover fires again on top of
/// that, so on a large fleet the catalog was re-scanning the whole raw topology dozens of times per
/// second per editing author. The catalog itself stays Scoped (per circuit); only this snapshot is
/// a singleton.
/// </para>
/// <para>
/// <b>The staleness trade-off is deliberate and bounded at <see cref="TtlSeconds"/> seconds.</b>
/// A tag saved in the AdminUI may be missing from completion — and hover over it may report "not a
/// known configured tag path" — for up to five seconds after the save. That is acceptable because
/// the catalog feeds <i>editor assistance</i> only: the authoritative checks (the deploy gate's
/// <c>DraftValidator</c> and the runtime's dependency mux) always read the live database, so a
/// momentarily-stale suggestion list can never let a bad script publish, nor block a good one for
/// longer than one TTL window. Do NOT reuse this cache for anything that decides whether a change
/// is <i>valid</i>.
/// </para>
/// <para>
/// <b>Concurrency.</b> The fresh path is a single volatile read with no locking. A stale path takes
/// a <see cref="SemaphoreSlim"/> and double-checks, so N circuits missing at the same instant share
/// ONE rebuild rather than launching N six-table scans at each other. A build that throws releases
/// the gate, propagates to its caller, and leaves any previous snapshot in place — a transient
/// database blip degrades to "one caller saw an error", never to "the cache now serves nothing".
/// </para>
/// </remarks>
/// <param name="timeProvider">Clock the TTL is measured against (<see cref="TimeProvider.System"/> in production).</param>
public sealed class ScriptTagCatalogSnapshotCache(TimeProvider timeProvider)
{
/// <summary>How long a built snapshot is served before the next caller rebuilds it.</summary>
private const int TtlSeconds = 5;
private static readonly TimeSpan Ttl = TimeSpan.FromSeconds(TtlSeconds);
private readonly SemaphoreSlim _rebuildGate = new(1, 1);
/// <summary>The current snapshot, or <see langword="null"/> before the first successful build.</summary>
private volatile Snapshot? _snapshot;
/// <summary>
/// Returns the cached projection when it is younger than the TTL, otherwise rebuilds it via
/// <paramref name="build"/> under a gate that collapses concurrent misses into one rebuild.
/// </summary>
/// <param name="build">Builds a fresh projection; invoked at most once per TTL window.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The catalog entries — a fresh snapshot, or one just built.</returns>
public async Task<IReadOnlyList<ScriptTagInfo>> GetOrBuildAsync(
Func<CancellationToken, Task<IReadOnlyList<ScriptTagInfo>>> build,
CancellationToken ct)
{
var current = _snapshot;
if (current is not null && !IsStale(current)) return current.Entries;
await _rebuildGate.WaitAsync(ct);
try
{
// Double-check: while we queued, the caller ahead of us may have rebuilt already.
current = _snapshot;
if (current is not null && !IsStale(current)) return current.Entries;
// A throw here leaves _snapshot untouched (the previous one, if any, keeps serving) and
// propagates to this caller only; the finally still releases the gate.
var entries = await build(ct);
// Stamped AFTER the build so a slow scan does not hand back an already-expired snapshot.
_snapshot = new Snapshot(entries, timeProvider.GetUtcNow());
return entries;
}
finally
{
_rebuildGate.Release();
}
}
private bool IsStale(Snapshot snapshot) => timeProvider.GetUtcNow() - snapshot.TakenAt >= Ttl;
private sealed record Snapshot(IReadOnlyList<ScriptTagInfo> Entries, DateTimeOffset TakenAt);
}