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
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
@@ -96,6 +97,11 @@ public static class EndpointRouteBuilderExtensions
// Roslyn-backed Monaco script-editor analysis (diagnostics/completions/hover/...).
services.AddScoped<ScriptAnalysis.ScriptAnalysisService>();
services.AddScoped<ScriptAnalysis.IScriptTagCatalog, ScriptAnalysis.ScriptTagCatalog>();
// The catalog stays Scoped (per circuit); its six-table fleet-wide projection is shared across
// circuits behind a 5 s TTL snapshot — Monaco fires a completion request per keystroke.
services.AddSingleton<ScriptAnalysis.ScriptTagCatalogSnapshotCache>();
// TryAdd so a host that already bound a clock (test harness, future fused-host registration) wins.
services.TryAddSingleton<TimeProvider>(TimeProvider.System);
// Certificate-store actions (trust/untrust/delete) for the /certificates page.
services.AddSingleton<Certificates.CertificateStoreManager>();
@@ -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);
}
@@ -35,18 +35,62 @@ public sealed class ScriptTagCatalogTests
private sealed class TestFactory(DbContextOptions<OtOpcUaConfigDbContext> opts)
: IDbContextFactory<OtOpcUaConfigDbContext>
{
public OtOpcUaConfigDbContext CreateDbContext() => new(opts);
/// <summary>Number of contexts handed out — one per DB scan, the thing the TTL cache elides.</summary>
private int _created;
public Task<OtOpcUaConfigDbContext> CreateDbContextAsync(CancellationToken ct = default) =>
Task.FromResult(new OtOpcUaConfigDbContext(opts));
/// <summary>Signalled the first time a context is requested (so a test can order two callers).</summary>
public TaskCompletionSource FirstRequest { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>When set, every async creation awaits this before returning — lets a test pin a build open.</summary>
public TaskCompletionSource? Gate { get; set; }
public int Created => Volatile.Read(ref _created);
public OtOpcUaConfigDbContext CreateDbContext()
{
Interlocked.Increment(ref _created);
return new OtOpcUaConfigDbContext(opts);
}
public async Task<OtOpcUaConfigDbContext> CreateDbContextAsync(CancellationToken ct = default)
{
Interlocked.Increment(ref _created);
FirstRequest.TrySetResult();
if (Gate is not null) await Gate.Task.WaitAsync(ct);
return new OtOpcUaConfigDbContext(opts);
}
}
/// <summary>A hand-driven clock — the test project has no <c>FakeTimeProvider</c> package reference.</summary>
private sealed class ManualTimeProvider(DateTimeOffset start) : TimeProvider
{
private long _ticks = start.UtcTicks;
public override DateTimeOffset GetUtcNow() => new(Volatile.Read(ref _ticks), TimeSpan.Zero);
public void Advance(TimeSpan by) => Volatile.Write(ref _ticks, Volatile.Read(ref _ticks) + by.Ticks);
}
private static (ScriptTagCatalog Catalog, DbContextOptions<OtOpcUaConfigDbContext> Opts) Fresh()
{
var (catalog, opts, _, _) = FreshInstrumented();
return (catalog, opts);
}
/// <summary>
/// Same catalog as <see cref="Fresh"/>, but exposing the scan-counting factory and the hand-driven
/// clock the snapshot-cache tests drive. Each call gets its OWN
/// <see cref="ScriptTagCatalogSnapshotCache"/> so the process-wide singleton never leaks across tests.
/// </summary>
private static (ScriptTagCatalog Catalog, DbContextOptions<OtOpcUaConfigDbContext> Opts, TestFactory Factory, ManualTimeProvider Time)
FreshInstrumented()
{
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase($"scripttagcatalog-{Guid.NewGuid():N}")
.Options;
return (new ScriptTagCatalog(new TestFactory(opts)), opts);
var factory = new TestFactory(opts);
var time = new ManualTimeProvider(new DateTimeOffset(2026, 8, 15, 12, 0, 0, TimeSpan.Zero));
return (new ScriptTagCatalog(factory, new ScriptTagCatalogSnapshotCache(time)), opts, factory, time);
}
/// <summary>
@@ -335,6 +379,107 @@ public sealed class ScriptTagCatalogTests
(await catalog.GetTagInfoAsync("Plant/Modbus/dev1/Speed", default)).ShouldNotBeNull();
}
// -----------------------------------------------------------------------
// ScriptTagCatalogSnapshotCache — the 5 s TTL in front of the six-table topology scan.
// -----------------------------------------------------------------------
// Monaco fires a completion request per keystroke inside a string literal, and each one used to
// re-scan RawFolders + DriverInstances + Devices + TagGroups + Tags + VirtualTags unfiltered.
// -----------------------------------------------------------------------
/// <summary>Two calls inside the TTL window scan the database exactly once — the whole point.</summary>
[Fact]
public async Task Two_calls_within_ttl_scan_the_db_once()
{
var (catalog, opts, factory, _) = FreshInstrumented();
Seed(opts);
var first = await catalog.GetPathsAsync(null, default);
var second = await catalog.GetPathsAsync(null, default);
factory.Created.ShouldBe(1);
second.ShouldBe(first);
second.ShouldContain("Plant/Modbus/dev1/Speed");
}
/// <summary>Past the TTL the snapshot is rebuilt, so a tag saved in the meantime becomes completable —
/// the staleness bound is 5 s, not forever.</summary>
[Fact]
public async Task An_expired_ttl_rebuilds_and_sees_new_tags()
{
var (catalog, opts, factory, time) = FreshInstrumented();
Seed(opts);
(await catalog.GetPathsAsync(null, default)).ShouldNotContain("Plant/Modbus/dev1/Pressure");
factory.Created.ShouldBe(1);
using (var db = new OtOpcUaConfigDbContext(opts))
{
db.Tags.Add(new Tag
{
TagId = "TAG-NEW", DeviceId = "DEV-1", Name = "Pressure",
DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
});
db.SaveChanges();
}
time.Advance(TimeSpan.FromSeconds(6));
var after = await catalog.GetPathsAsync(null, default);
factory.Created.ShouldBe(2);
after.ShouldContain("Plant/Modbus/dev1/Pressure");
}
/// <summary>Two circuits missing at the same instant share ONE rebuild — without the gate the second
/// caller would start its own six-table scan while the first is still in flight.</summary>
[Fact]
public async Task Concurrent_misses_share_one_rebuild()
{
var (catalog, opts, factory, _) = FreshInstrumented();
Seed(opts);
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
factory.Gate = gate;
var first = Task.Run(() => catalog.GetPathsAsync(null, default));
await factory.FirstRequest.Task.WaitAsync(TimeSpan.FromSeconds(10));
var second = Task.Run(() => catalog.GetPathsAsync(null, default));
// Give the second caller ample time to reach the cache. It must be parked on the rebuild gate,
// NOT scanning: a second scan here is exactly the duplicated work this test falsifies.
for (var i = 0; i < 50 && factory.Created < 2; i++) await Task.Delay(10);
factory.Created.ShouldBe(1);
gate.SetResult();
var a = await first.WaitAsync(TimeSpan.FromSeconds(10));
var b = await second.WaitAsync(TimeSpan.FromSeconds(10));
factory.Created.ShouldBe(1);
a.ShouldContain("Plant/Modbus/dev1/Speed");
b.ShouldBe(a);
}
/// <summary>A failed rebuild propagates and leaves the previous snapshot intact — a transient DB blip
/// must not poison the cache into serving nothing.</summary>
[Fact]
public async Task A_failed_rebuild_does_not_poison_the_cache()
{
var time = new ManualTimeProvider(new DateTimeOffset(2026, 8, 15, 12, 0, 0, TimeSpan.Zero));
var cache = new ScriptTagCatalogSnapshotCache(time);
IReadOnlyList<ScriptTagInfo> good = [new ScriptTagInfo("A/B", "Tag", "Float", "DRV-1")];
(await cache.GetOrBuildAsync(_ => Task.FromResult(good), default)).ShouldBe(good);
time.Advance(TimeSpan.FromSeconds(6));
await Should.ThrowAsync<InvalidOperationException>(
cache.GetOrBuildAsync(_ => Task.FromException<IReadOnlyList<ScriptTagInfo>>(new InvalidOperationException("boom")), default));
// The gate was released and the old snapshot survived, so the next build still runs and succeeds.
(await cache.GetOrBuildAsync(_ => Task.FromResult(good), default)).ShouldBe(good);
}
// -----------------------------------------------------------------------
// GetEquipmentReferenceNamesAsync — DB-backed tests (v3 reference-relative {{equip}}/<RefName>)
// -----------------------------------------------------------------------