perf(adminui): 5s snapshot cache in front of the script tag catalog
This commit is contained in:
+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