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