From 3795899d10ab66b08dd40cdb774c51a6404c4e1a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 16 Aug 2026 04:05:07 -0400 Subject: [PATCH] perf(adminui): 5s snapshot cache in front of the script tag catalog --- .../EndpointRouteBuilderExtensions.cs | 6 + .../ScriptAnalysis/IScriptTagCatalog.cs | 22 ++- .../ScriptTagCatalogSnapshotCache.cs | 86 ++++++++++ .../ScriptAnalysis/ScriptTagCatalogTests.cs | 153 +++++++++++++++++- 4 files changed, 259 insertions(+), 8 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptTagCatalogSnapshotCache.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs index c0e33b27..6373b890 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs @@ -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(); services.AddScoped(); + // 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(); + // TryAdd so a host that already bound a clock (test harness, future fused-host registration) wins. + services.TryAddSingleton(TimeProvider.System); // Certificate-store actions (trust/untrust/delete) for the /certificates page. services.AddSingleton(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs index a0ee9569..1d3347ca 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs @@ -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. /// /// -/// 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 /// UnsTreeService uses — so the service is safe to register Scoped per Blazor circuit. +/// The factory really is pooled: ServiceCollectionExtensions.AddOtOpcUaConfigDb registers it +/// with AddPooledDbContextFactory, so a per-call create is a rent from the pool. +/// +/// +/// The six-table projection is cached for 5 s behind the singleton +/// , 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. +/// is deliberately NOT cached: it is two +/// equipment-scoped queries, not a fleet-wide scan. /// /// -public sealed class ScriptTagCatalog(IDbContextFactory dbFactory) : IScriptTagCatalog +/// Pooled ConfigDb context factory. +/// Process-wide 5 s snapshot of the fleet-wide projection. +public sealed class ScriptTagCatalog( + IDbContextFactory dbFactory, + ScriptTagCatalogSnapshotCache cache) : IScriptTagCatalog { /// Upper bound on returned suggestions — keeps the completion list responsive on large fleets. private const int MaxResults = 200; @@ -91,7 +105,7 @@ public sealed class ScriptTagCatalog(IDbContextFactory d /// public async Task> GetPathsAsync(string? filter, CancellationToken ct) { - var entries = await BuildEntriesAsync(ct); + var entries = await cache.GetOrBuildAsync(BuildEntriesAsync, ct); var paths = new HashSet(entries.Select(e => e.Path), StringComparer.OrdinalIgnoreCase); @@ -110,7 +124,7 @@ public sealed class ScriptTagCatalog(IDbContextFactory d /// public async Task 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 diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptTagCatalogSnapshotCache.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptTagCatalogSnapshotCache.cs new file mode 100644 index 00000000..c6597fae --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptTagCatalogSnapshotCache.cs @@ -0,0 +1,86 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; + +/// +/// A short-TTL, process-wide snapshot of the 's projection, shared by +/// every Blazor circuit and every script-analysis endpoint call. +/// +/// +/// +/// Why. Building the projection is SIX unfiltered table scans — RawFolders, +/// DriverInstances, Devices, TagGroups, Tags, VirtualTags — plus +/// a RawPathResolver pass over every tag. Monaco fires a completion request on every +/// keystroke inside a ctx.GetTag("…") 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. +/// +/// +/// The staleness trade-off is deliberate and bounded at seconds. +/// 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 editor assistance only: the authoritative checks (the deploy gate's +/// DraftValidator 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 valid. +/// +/// +/// Concurrency. The fresh path is a single volatile read with no locking. A stale path takes +/// a 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". +/// +/// +/// Clock the TTL is measured against ( in production). +public sealed class ScriptTagCatalogSnapshotCache(TimeProvider timeProvider) +{ + /// How long a built snapshot is served before the next caller rebuilds it. + private const int TtlSeconds = 5; + + private static readonly TimeSpan Ttl = TimeSpan.FromSeconds(TtlSeconds); + + private readonly SemaphoreSlim _rebuildGate = new(1, 1); + + /// The current snapshot, or before the first successful build. + private volatile Snapshot? _snapshot; + + /// + /// Returns the cached projection when it is younger than the TTL, otherwise rebuilds it via + /// under a gate that collapses concurrent misses into one rebuild. + /// + /// Builds a fresh projection; invoked at most once per TTL window. + /// Cancellation token. + /// The catalog entries — a fresh snapshot, or one just built. + public async Task> GetOrBuildAsync( + Func>> 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 Entries, DateTimeOffset TakenAt); +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogTests.cs index fa82c464..97c841ef 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogTests.cs @@ -35,18 +35,62 @@ public sealed class ScriptTagCatalogTests private sealed class TestFactory(DbContextOptions opts) : IDbContextFactory { - public OtOpcUaConfigDbContext CreateDbContext() => new(opts); + /// Number of contexts handed out — one per DB scan, the thing the TTL cache elides. + private int _created; - public Task CreateDbContextAsync(CancellationToken ct = default) => - Task.FromResult(new OtOpcUaConfigDbContext(opts)); + /// Signalled the first time a context is requested (so a test can order two callers). + public TaskCompletionSource FirstRequest { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// When set, every async creation awaits this before returning — lets a test pin a build open. + 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 CreateDbContextAsync(CancellationToken ct = default) + { + Interlocked.Increment(ref _created); + FirstRequest.TrySetResult(); + if (Gate is not null) await Gate.Task.WaitAsync(ct); + return new OtOpcUaConfigDbContext(opts); + } + } + + /// A hand-driven clock — the test project has no FakeTimeProvider package reference. + 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 Opts) Fresh() + { + var (catalog, opts, _, _) = FreshInstrumented(); + return (catalog, opts); + } + + /// + /// Same catalog as , but exposing the scan-counting factory and the hand-driven + /// clock the snapshot-cache tests drive. Each call gets its OWN + /// so the process-wide singleton never leaks across tests. + /// + private static (ScriptTagCatalog Catalog, DbContextOptions Opts, TestFactory Factory, ManualTimeProvider Time) + FreshInstrumented() { var opts = new DbContextOptionsBuilder() .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); } /// @@ -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. + // ----------------------------------------------------------------------- + + /// Two calls inside the TTL window scan the database exactly once — the whole point. + [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"); + } + + /// Past the TTL the snapshot is rebuilt, so a tag saved in the meantime becomes completable — + /// the staleness bound is 5 s, not forever. + [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"); + } + + /// 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. + [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); + } + + /// A failed rebuild propagates and leaves the previous snapshot intact — a transient DB blip + /// must not poison the cache into serving nothing. + [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 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( + cache.GetOrBuildAsync(_ => Task.FromException>(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}}/) // -----------------------------------------------------------------------