perf(adminui): 5s snapshot cache in front of the script tag catalog
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user