perf(adminui): pooled DbContext factory; role-grants reads per-call, not per-circuit

This commit is contained in:
Joseph Doherty
2026-08-16 04:05:34 -04:00
parent 34f836e03c
commit 140bf0c131
11 changed files with 346 additions and 50 deletions
@@ -139,7 +139,10 @@ internal static class UnsTreeTestDb
/// <summary>
/// Minimal <see cref="IDbContextFactory{TContext}"/> that hands back contexts sharing a
/// single InMemory database name — the test-side stand-in for the runtime pooled factory.
/// single InMemory database name — the test-side stand-in for the runtime factory, which IS
/// pooled (<c>AddPooledDbContextFactory</c> in <c>ServiceCollectionExtensions.AddOtOpcUaConfigDb</c>).
/// This stand-in builds a fresh context per call rather than renting one: pooling is a
/// construction-cost optimisation, and nothing under test can observe the difference.
/// </summary>
private sealed class NamedFactory(string name) : IDbContextFactory<OtOpcUaConfigDbContext>
{
@@ -25,20 +25,21 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-3", 30, OpcUaStatus.Good, DateTime.UtcNow));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-2", 20, OpcUaStatus.Good, DateTime.UtcNow));
// The mux emits ONLY batches — the per-value comms-loss ingress fans out as a single-item batch.
// subA hears tag-1 + tag-2.
var aMsgs = new[]
{
subA.ExpectMsg<VirtualTagActor.DependencyValueChanged>(),
subA.ExpectMsg<VirtualTagActor.DependencyValueChanged>(),
}.OrderBy(m => m.TagId).ToList();
subA.ExpectMsg<VirtualTagActor.DependencyValueBatch>(),
subA.ExpectMsg<VirtualTagActor.DependencyValueBatch>(),
}.Select(b => b.Items.ShouldHaveSingleItem()).OrderBy(m => m.TagId).ToList();
aMsgs.Select(m => m.TagId).ShouldBe(new[] { "tag-1", "tag-2" });
// subB hears tag-3 + tag-2.
var bMsgs = new[]
{
subB.ExpectMsg<VirtualTagActor.DependencyValueChanged>(),
subB.ExpectMsg<VirtualTagActor.DependencyValueChanged>(),
}.OrderBy(m => m.TagId).ToList();
subB.ExpectMsg<VirtualTagActor.DependencyValueBatch>(),
subB.ExpectMsg<VirtualTagActor.DependencyValueBatch>(),
}.Select(b => b.Items.ShouldHaveSingleItem()).OrderBy(m => m.TagId).ToList();
bMsgs.Select(m => m.TagId).ShouldBe(new[] { "tag-2", "tag-3" });
subA.ExpectNoMsg(TimeSpan.FromMilliseconds(100));
@@ -67,7 +68,7 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "tag-1" }, sub.Ref));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-1", 10, OpcUaStatus.Good, DateTime.UtcNow));
sub.ExpectMsg<VirtualTagActor.DependencyValueChanged>();
sub.ExpectMsg<VirtualTagActor.DependencyValueBatch>();
mux.Tell(new DependencyMuxActor.UnregisterInterest(sub.Ref));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-1", 20, OpcUaStatus.Good, DateTime.UtcNow));
@@ -88,7 +89,7 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
sub.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-2", 20, OpcUaStatus.Good, DateTime.UtcNow));
sub.ExpectMsg<VirtualTagActor.DependencyValueChanged>().TagId.ShouldBe("tag-2");
sub.ExpectMsg<VirtualTagActor.DependencyValueBatch>().Items.ShouldHaveSingleItem().TagId.ShouldBe("tag-2");
}
/// <summary>Verifies that a virtual tag actor registers dependencies with the mux during PreStart and that evaluation fires end-to-end.</summary>
@@ -148,12 +149,13 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
new DriverInstanceActor.AttributeValueItem("ref-1", 42, OpcUaStatus.Good, DateTime.UtcNow),
}));
subscriber.ExpectMsg<VirtualTagActor.DependencyValueChanged>().TagId.ShouldBe("ref-1");
subscriber.ExpectMsg<VirtualTagActor.DependencyValueBatch>()
.Items.ShouldHaveSingleItem().TagId.ShouldBe("ref-1");
}
/// <summary>A batched driver burst (the primary ingress from DriverHostActor since the value-pipeline
/// batching work) is filtered per item: a subscriber hears exactly one DependencyValueChanged for the
/// ref it registered, carrying that item's own value/timestamp/quality, and nothing for the rest of the
/// batching work) is filtered per item: a subscriber hears one batch carrying exactly the item for the
/// ref it registered, with that item's own value/timestamp/quality, and nothing for the rest of the
/// burst.</summary>
[Fact]
public void AttributeValueBatchPublished_is_filtered_per_item_to_interested_subscribers()
@@ -170,14 +172,43 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
new DriverInstanceActor.AttributeValueItem("tag-x", 10, OpcUaStatus.BadNoCommunication, tsX),
}));
var dep = sub.ExpectMsg<VirtualTagActor.DependencyValueChanged>(PresenceBudget);
// Nothing for tag-y — no subscriber registered for it, so the batch carries tag-x alone.
var dep = sub.ExpectMsg<VirtualTagActor.DependencyValueBatch>(PresenceBudget).Items.ShouldHaveSingleItem();
dep.TagId.ShouldBe("tag-x");
dep.Value.ShouldBe(10);
dep.TimestampUtc.ShouldBe(tsX);
// #519: the item's own status sub-code rides through, not a Good/Bad bucket.
dep.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
// Nothing for tag-y — no subscriber registered for it.
sub.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
/// <summary>One driver burst costs each subscriber exactly ONE mailbox message, not one per matched
/// item: the mux accumulates each subscriber's matched slice and Tells it as a single
/// <c>DependencyValueBatch</c>. The unmatched item never reaches the subscriber, and no second message
/// follows the batch.</summary>
[Fact]
public void A_burst_reaches_each_subscriber_as_one_batch_message()
{
var mux = Sys.ActorOf(DependencyMuxActor.Props());
var sub = CreateTestProbe();
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "a", "b" }, sub.Ref));
var tsA = new DateTime(2026, 8, 15, 10, 0, 0, DateTimeKind.Utc);
var tsB = new DateTime(2026, 8, 15, 10, 0, 1, DateTimeKind.Utc);
mux.Tell(new DriverInstanceActor.AttributeValueBatchPublished("driver-1", new[]
{
new DriverInstanceActor.AttributeValueItem("a", 1, OpcUaStatus.Good, tsA),
new DriverInstanceActor.AttributeValueItem("b", 2, OpcUaStatus.Good, tsB),
new DriverInstanceActor.AttributeValueItem("c", 3, OpcUaStatus.Good, tsB),
}));
var batch = sub.ExpectMsg<VirtualTagActor.DependencyValueBatch>(PresenceBudget);
batch.Items.Select(i => i.TagId).ShouldBe(new[] { "a", "b" });
batch.Items.Select(i => i.Value).ShouldBe(new object?[] { 1, 2 });
batch.Items.Select(i => i.TimestampUtc).ShouldBe(new[] { tsA, tsB });
// The unmatched "c" produced nothing, and the two matched items did NOT arrive as two messages.
sub.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
@@ -193,7 +224,8 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
mux.Tell(new DriverInstanceActor.AttributeValuePublished(
"driver-1", "tag-1", 10, OpcUaStatus.Bad, DateTime.UtcNow));
sub.ExpectMsg<VirtualTagActor.DependencyValueChanged>().StatusCode.ShouldBe(OpcUaStatus.Bad);
sub.ExpectMsg<VirtualTagActor.DependencyValueBatch>()
.Items.ShouldHaveSingleItem().StatusCode.ShouldBe(OpcUaStatus.Bad);
}
private sealed class EchoSumEvaluator : ZB.MOM.WW.OtOpcUa.Commons.Engines.IVirtualTagEvaluator
@@ -205,6 +205,77 @@ public sealed class VirtualTagActorTests : RuntimeActorTestBase
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().StatusCode.ShouldBe(OpcUaStatus.BadInternalError);
}
/// <summary>A batch carrying K changed inputs evaluates the script ONCE, with every input already
/// applied — not once per input. The emitted result is computed from all three values (7 = 1+2+4), so
/// the collapse cannot be an evaluation that simply missed the later items.</summary>
[Fact]
public void A_batch_touching_K_inputs_evaluates_once()
{
var parent = CreateTestProbe();
var evaluator = new CountingSumEvaluator();
var actor = parent.ChildActorOf(VirtualTagActor.Props("vt-1", "a+b+c", evaluator: evaluator));
var ts = new DateTime(2026, 8, 15, 10, 0, 0, DateTimeKind.Utc);
actor.Tell(new VirtualTagActor.DependencyValueBatch(new[]
{
new VirtualTagActor.DependencyValueChanged("a", 1, ts.AddSeconds(-2)),
new VirtualTagActor.DependencyValueChanged("b", 2, ts.AddSeconds(-1)),
new VirtualTagActor.DependencyValueChanged("c", 4, ts),
}));
var result = parent.ExpectMsg<VirtualTagActor.EvaluationResult>();
result.Value.ShouldBe(7);
// The single evaluation is stamped with the newest item in the burst.
result.TimestampUtc.ShouldBe(ts);
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
evaluator.Calls.ShouldBe(1);
}
/// <summary>Falsification control for <see cref="A_batch_touching_K_inputs_evaluates_once"/>: the SAME
/// three values delivered as three separate <c>DependencyValueChanged</c> messages evaluate three times
/// and emit three results (1 → 3 → 7, all distinct so the value dedup cannot collapse them). Without
/// this, a batch test that counted 1 would also pass against a build where dedup — not the batch
/// apply-then-evaluate split — did the collapsing.</summary>
[Fact]
public void The_same_values_sent_singly_evaluate_once_per_message()
{
var parent = CreateTestProbe();
var evaluator = new CountingSumEvaluator();
var actor = parent.ChildActorOf(VirtualTagActor.Props("vt-1", "a+b+c", evaluator: evaluator));
var ts = new DateTime(2026, 8, 15, 10, 0, 0, DateTimeKind.Utc);
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 1, ts.AddSeconds(-2)));
actor.Tell(new VirtualTagActor.DependencyValueChanged("b", 2, ts.AddSeconds(-1)));
actor.Tell(new VirtualTagActor.DependencyValueChanged("c", 4, ts));
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().Value.ShouldBe(1);
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().Value.ShouldBe(3);
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().Value.ShouldBe(7);
evaluator.Calls.ShouldBe(3);
}
/// <summary>Test evaluator that sums integer dependencies and counts how many times it was called.</summary>
private sealed class CountingSumEvaluator : IVirtualTagEvaluator
{
private int _calls;
/// <summary>Gets the number of <see cref="Evaluate"/> calls observed so far.</summary>
public int Calls => Volatile.Read(ref _calls);
/// <summary>Counts the call and evaluates the expression by summing integer dependencies.</summary>
/// <param name="id">The tag identifier.</param>
/// <param name="expr">The expression string.</param>
/// <param name="deps">The dependency values.</param>
/// <returns>The sum of integer values in the dependencies.</returns>
public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary<string, object?> deps)
{
Interlocked.Increment(ref _calls);
return VirtualTagEvalResult.Ok(deps.Values.OfType<int>().Sum());
}
}
/// <summary>Test evaluator returning a fixed sequence of results, one per Evaluate call; the last
/// result repeats once the queue is drained.</summary>
private sealed class QueuedEvaluator : IVirtualTagEvaluator