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
@@ -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>)
// -----------------------------------------------------------------------