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
@@ -22,15 +22,29 @@ public static class ServiceCollectionExtensions
?? throw new InvalidOperationException(
$"Connection string '{ConnectionStringName}' is required. Add it to appsettings.json or the OTOPCUA_CONFIG_CONNECTION env var.");
services.AddDbContextFactory<OtOpcUaConfigDbContext>(opt => opt.UseSqlServer(connectionString));
// POOLED, deliberately: every consumer in this repo takes the IDbContextFactory<> and creates
// a short-lived context per call/query, so context construction (and its model/service-provider
// lookup) is on the hot path of every AdminUI render. Pooling makes each CreateDbContext a rent
// from a reset instance instead of a fresh build. OtOpcUaConfigDbContext qualifies: one public
// ctor taking DbContextOptions<>, no injected scoped services, no instance state beyond EF's own.
services.AddPooledDbContextFactory<OtOpcUaConfigDbContext>(opt => opt.UseSqlServer(connectionString));
// AddDbContextFactory registers only the IDbContextFactory<> — it does NOT also register
// a scoped OtOpcUaConfigDbContext. Config services that take the context directly (e.g.
// LdapGroupRoleMappingService) need a scoped instance, so bridge one off the factory.
// AddPooledDbContextFactory registers only the IDbContextFactory<> — it does NOT also register
// a scoped OtOpcUaConfigDbContext. This bridge exists for ONE consumer that cannot be moved to
// the factory because it is not ours: ASP.NET Core Data Protection's EF Core key repository
// (Security's AddDataProtection().PersistKeysToDbContext<OtOpcUaConfigDbContext>()), which
// resolves TContext with CreateScope() + GetRequiredService<TContext>() and has no factory
// support. Deleting this bridge breaks cookie-key persistence — i.e. AdminUI login — at runtime,
// not at build time. Nothing else should take OtOpcUaConfigDbContext from DI directly; take the
// factory and own the context's lifetime per call, as LdapGroupRoleMappingService does. Disposing
// a pooled context returns it to the pool, so the scope end is a release, not a teardown.
services.AddScoped(sp => sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>().CreateDbContext());
// Config-DB services consumed by both the AdminUI (RoleGrants page) and the auth/login
// host (AuthEndpoints.LoginAsync). Scoped to match the request/render scope of both callers.
// host (AuthEndpoints.LoginAsync). Scoped is a registration-shape choice, not a data-access
// one: the driver-role block TryAdds NullLdapGroupRoleMappingService at the same lifetime, and
// the service itself is stateless — it creates and disposes a context per call off the pooled
// factory rather than pinning one for the scope (a Blazor circuit is a single long scope).
services.AddScoped<ILdapGroupRoleMappingService, LdapGroupRoleMappingService>();
return services;
@@ -8,7 +8,18 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Services;
/// "exactly one of (ClusterId, IsSystemWide)" invariant at the write surface so a
/// malformed row can't land in the DB.
/// </summary>
public sealed class LdapGroupRoleMappingService(OtOpcUaConfigDbContext db) : ILdapGroupRoleMappingService
/// <remarks>
/// Takes the <see cref="IDbContextFactory{TContext}"/> and creates + disposes one context PER CALL
/// rather than holding an injected context for its lifetime. Its consumers are the RoleGrants Blazor
/// page (InteractiveServer, so its DI scope is the whole circuit) and <c>AuthEndpoints.LoginAsync</c>;
/// an injected context would pin one context — and one pooled connection — for the life of a circuit,
/// and would fault outright ("a second operation was started on this context instance") the moment two
/// of that page's renders overlapped. The factory is pooled (see
/// <c>ServiceCollectionExtensions.AddOtOpcUaConfigDb</c>), so the per-call create is a pool rent, not
/// a fresh context build.
/// </remarks>
public sealed class LdapGroupRoleMappingService(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory)
: ILdapGroupRoleMappingService
{
/// <inheritdoc />
public async Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
@@ -18,6 +29,7 @@ public sealed class LdapGroupRoleMappingService(OtOpcUaConfigDbContext db) : ILd
var groupSet = ldapGroups.ToList();
if (groupSet.Count == 0) return [];
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
return await db.LdapGroupRoleMappings
.AsNoTracking()
.Where(m => groupSet.Contains(m.LdapGroup))
@@ -27,12 +39,15 @@ public sealed class LdapGroupRoleMappingService(OtOpcUaConfigDbContext db) : ILd
/// <inheritdoc />
public async Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken)
=> await db.LdapGroupRoleMappings
{
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
return await db.LdapGroupRoleMappings
.AsNoTracking()
.OrderBy(m => m.LdapGroup)
.ThenBy(m => m.ClusterId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken)
@@ -43,6 +58,7 @@ public sealed class LdapGroupRoleMappingService(OtOpcUaConfigDbContext db) : ILd
if (row.Id == Guid.Empty) row.Id = Guid.NewGuid();
if (row.CreatedAtUtc == default) row.CreatedAtUtc = DateTime.UtcNow;
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
db.LdapGroupRoleMappings.Add(row);
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return row;
@@ -51,6 +67,7 @@ public sealed class LdapGroupRoleMappingService(OtOpcUaConfigDbContext db) : ILd
/// <inheritdoc />
public async Task DeleteAsync(Guid id, CancellationToken cancellationToken)
{
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var existing = await db.LdapGroupRoleMappings.FindAsync([id], cancellationToken).ConfigureAwait(false);
if (existing is null) return;
db.LdapGroupRoleMappings.Remove(existing);
@@ -240,6 +240,7 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
_maxWriteAttempts = maxWriteAttempts > 0 ? maxWriteAttempts : 1;
ReceiveAsync<VirtualTagActor.DependencyValueChanged>(OnValueChangedAsync);
ReceiveAsync<VirtualTagActor.DependencyValueBatch>(OnValueBatchAsync);
Receive<UpdateHistorizedRefs>(OnUpdateHistorizedRefs);
Receive<DrainTick>(_ => OnDrainTick());
Receive<DrainResult>(OnDrainResult);
@@ -275,6 +276,30 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
base.PostStop();
}
/// <summary>
/// The mux's batched delivery: one message carrying this recorder's whole matched slice of a driver
/// burst. Each item is appended by the existing per-value handler, in order — the saving here is the
/// mailbox hop, not the append (a genuinely batched append is a later change).
/// <para>
/// ⚠️ The await below deliberately does NOT use <c>ConfigureAwait(false)</c>. <see
/// cref="OnValueChangedAsync"/> reads <c>Self</c> before its own first await, so it must start
/// each item ON the actor thread; a context-dropping await here would leave item 2 onward with
/// no ActorContext and throw <c>NotSupportedException</c> — the Gitea #491 failure, which
/// supervision turns into a restart that silently discards every historized ref. A plain await
/// resumes on Akka's ActorTaskScheduler context, so the context is restored before the next
/// iteration.
/// </para>
/// </summary>
/// <param name="msg">The batch of matched dependency values.</param>
private async Task OnValueBatchAsync(VirtualTagActor.DependencyValueBatch msg)
{
IReadOnlyList<VirtualTagActor.DependencyValueChanged> items = msg.Items;
for (int i = 0; i < items.Count; i++)
{
await OnValueChangedAsync(items[i]);
}
}
private async Task OnValueChangedAsync(VirtualTagActor.DependencyValueChanged msg)
{
// The mux fans values keyed by the driver ref (msg.TagId == the mux ref). Only historize refs we
@@ -201,6 +201,10 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
Receive<ApplyScriptedAlarms>(OnApply);
Receive<AlarmsLoaded>(OnAlarmsLoaded);
Receive<VirtualTagActor.DependencyValueChanged>(OnDependencyChanged);
// The mux's batched delivery: one message carrying this host's whole matched slice of a driver
// burst. The engine upstream is fed per value, so push each item in order — the saving is the
// mailbox hop, not the push.
Receive<VirtualTagActor.DependencyValueBatch>(OnDependencyBatch);
Receive<EngineEmission>(OnEngineEmission);
// Cluster redundancy snapshots (published on the `redundancy-state` topic, subscribed in PreStart)
// cache this node's role so OnEngineEmission can gate the cluster-wide alerts publish to the Primary.
@@ -395,6 +399,17 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
new DataValueSnapshot(msg.Value, msg.StatusCode, msg.TimestampUtc, msg.TimestampUtc));
}
/// <summary>One batched mux delivery — every item already matched this host's registered interest.
/// Loops the per-value handler, preserving in-burst order.</summary>
private void OnDependencyBatch(VirtualTagActor.DependencyValueBatch msg)
{
var items = msg.Items;
for (var i = 0; i < items.Count; i++)
{
OnDependencyChanged(items[i]);
}
}
private void OnEngineEmission(EngineEmission msg)
{
var e = msg.Event;
@@ -45,6 +45,7 @@ public sealed class DependencyConsumerMuxAdapter : ReceiveActor
_mux = mux ?? throw new ArgumentNullException(nameof(mux));
Receive<VirtualTagActor.DependencyValueChanged>(OnDependencyValueChanged);
Receive<VirtualTagActor.DependencyValueBatch>(OnDependencyValueBatch);
Receive<ReRegister>(_ => RegisterInterest());
}
@@ -68,4 +69,16 @@ public sealed class DependencyConsumerMuxAdapter : ReceiveActor
// Quality is stripped at the mux boundary (value + timestamp only), so forward Good (0).
_consumer.OnDependencyValue(msg.TagId, msg.Value, statusCode: 0u, msg.TimestampUtc);
}
/// <summary>The mux's batched delivery: one message carrying this adapter's whole matched slice of a
/// driver burst. The consumer contract is per value, so dispatch each item in order — the saving is the
/// mailbox hop, not the consumer call.</summary>
private void OnDependencyValueBatch(VirtualTagActor.DependencyValueBatch msg)
{
var items = msg.Items;
for (var i = 0; i < items.Count; i++)
{
OnDependencyValueChanged(items[i]);
}
}
}
@@ -8,9 +8,9 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
/// Per-node fan-out router from <see cref="DriverInstanceActor.AttributeValuePublished"/>
/// to interested <see cref="VirtualTagActor"/> instances. VirtualTagActor sends
/// <see cref="RegisterInterest"/> on start-up listing the tag refs it depends on; the mux
/// keeps a map of <c>tagRef → Set&lt;IActorRef&gt;</c> and on every AttributeValuePublished
/// forwards a <see cref="VirtualTagActor.DependencyValueChanged"/> to each interested
/// subscriber.
/// keeps a map of <c>tagRef → Set&lt;IActorRef&gt;</c> and on every ingress forwards each
/// interested subscriber ONE <see cref="VirtualTagActor.DependencyValueBatch"/> carrying that
/// subscriber's matched slice of the burst.
///
/// DriverHostActor forwards driver-child value publishes to this mux (one mux per driver-role
/// node). The ONLY driver ingress is the batched
@@ -18,8 +18,11 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
/// production burst, which the mux filters per item. The per-value
/// <see cref="DriverInstanceActor.AttributeValuePublished"/> receive stays for the comms-loss fan
/// ALONE: <c>DriverHostActor.FanCommsLossToDependencyMux</c> fabricates that message and Tells this
/// mux directly, never through the host's own receive. Both
/// produce the same <see cref="VirtualTagActor.DependencyValueChanged"/> per interested subscriber.
/// mux directly, never through the host's own receive.
///
/// <b>The mux emits ONLY batches</b> (the comms-loss fan as a single-item one), so a subscriber's
/// batch receive is its complete hot path. Every subscriber keeps a per-value
/// <see cref="VirtualTagActor.DependencyValueChanged"/> receive as well, for direct senders and tests.
/// The mux is deliberately not a DPS subscriber — virtual-tag evaluation is local to each node and
/// would over-emit if it spanned the cluster.
/// </summary>
@@ -110,14 +113,20 @@ public sealed class DependencyMuxActor : ReceiveActor
var dep = new VirtualTagActor.DependencyValueChanged(msg.FullReference, msg.Value, msg.TimestampUtc, msg.StatusCode);
foreach (var sub in subscribers)
{
sub.Tell(dep);
// A single-item batch, so the mux emits exactly ONE message shape and every subscriber needs
// only the batch receive on its hot path. One list per subscriber: batch ownership transfers
// to the receiver, so a shared list must never cross mailboxes. This is the comms-loss fan
// (one ref, on a driver disconnect), not the steady value path — the extra allocation is noise.
sub.Tell(new VirtualTagActor.DependencyValueBatch(new[] { dep }));
}
}
/// <summary>Filters one batched driver production burst per item — the batched analogue of
/// <see cref="OnAttributeValuePublished"/>. Items nobody registered for are dropped (the common case:
/// only a fraction of an address space feeds virtual-tag expressions), so a burst of N values costs one
/// message plus one DependencyValueChanged per genuinely interested (item, subscriber) pair.
/// <summary>Filters one batched driver production burst per item and delivers each subscriber's matched
/// slice as ONE <see cref="VirtualTagActor.DependencyValueBatch"/>. Items nobody registered for are
/// dropped (the common case: only a fraction of an address space feeds virtual-tag expressions), so a
/// burst of N values costs one inbound message plus one outbound message per genuinely interested
/// SUBSCRIBER — not per (item, subscriber) pair as it did when this fanned out per value. That is what
/// lets <see cref="VirtualTagActor"/> evaluate its script once per burst instead of once per input.
/// <para>The registered set is NOT just virtual-tag inputs: <c>ContinuousHistorizationRecorder</c>
/// registers interest in every historized ref, so on a historizing node <see cref="_byRef"/> can be
/// large and a sizeable fraction of each burst resolves.</para></summary>
@@ -126,6 +135,10 @@ public sealed class DependencyMuxActor : ReceiveActor
// Nobody has registered anything — skip the whole burst rather than probing an empty map per item.
if (_byRef.Count == 0) return;
// Allocated lazily on the first matched item: a burst in which nothing is registered for — still
// the common case on a node with a handful of virtual tags — allocates nothing at all.
Dictionary<IActorRef, List<VirtualTagActor.DependencyValueChanged>>? perSubscriber = null;
// Indexed for-loop, not foreach: this is the hot value ingress and an IReadOnlyList<T> enumerator
// would allocate once per burst.
var count = msg.Items.Count;
@@ -134,7 +147,25 @@ public sealed class DependencyMuxActor : ReceiveActor
var item = msg.Items[i];
if (!_byRef.TryGetValue(item.FullReference, out var subscribers) || subscribers.Count == 0) continue;
var dep = new VirtualTagActor.DependencyValueChanged(item.FullReference, item.Value, item.TimestampUtc, item.StatusCode);
foreach (var sub in subscribers) sub.Tell(dep);
perSubscriber ??= new Dictionary<IActorRef, List<VirtualTagActor.DependencyValueChanged>>();
foreach (var sub in subscribers)
{
if (!perSubscriber.TryGetValue(sub, out var list))
{
list = new List<VirtualTagActor.DependencyValueChanged>();
perSubscriber[sub] = list;
}
list.Add(dep);
}
}
if (perSubscriber is null) return;
// One message per subscriber, carrying that subscriber's own list. The lists are built here and
// never touched again, so handing ownership to the receiver is safe.
foreach (var (sub, list) in perSubscriber)
{
sub.Tell(new VirtualTagActor.DependencyValueBatch(list));
}
}
}
@@ -37,6 +37,13 @@ public sealed class VirtualTagActor : ReceiveActor
public sealed record DependencyValueChanged(
string TagId, object? Value, DateTime TimestampUtc, uint StatusCode = OpcUaStatus.Good);
/// <summary>
/// One subscriber's slice of one upstream burst: every item in this batch matched
/// this subscriber's registered interest set. Never empty. List ownership transfers
/// to the receiver (the mux allocates a fresh list per subscriber per burst).
/// </summary>
public sealed record DependencyValueBatch(IReadOnlyList<DependencyValueChanged> Items);
/// <summary>Re-assert the last emitted value/quality to the parent bridge, bypassing the value dedup.
/// Sent by <see cref="VirtualTagHostActor"/> on every apply (deploy) to a surviving child: a deploy
/// re-materialises the child's published UNS node to <c>BadWaitingForInitialData</c>, but an
@@ -132,6 +139,7 @@ public sealed class VirtualTagActor : ReceiveActor
_telemetryHub = telemetryHub;
Receive<DependencyValueChanged>(OnDependencyChanged);
Receive<DependencyValueBatch>(OnDependencyBatch);
Receive<ReassertValue>(_ => OnReassertValue());
}
@@ -179,7 +187,41 @@ public sealed class VirtualTagActor : ReceiveActor
private void OnDependencyChanged(DependencyValueChanged msg)
{
_dependencies[msg.TagId] = msg.Value;
Evaluate(msg.TimestampUtc);
}
/// <summary>Applies EVERY item in the batch to the dependency map first, then evaluates the script
/// ONCE. This is the whole point of the batched mux delivery: a burst that moves K of this tag's inputs
/// used to arrive as K messages and cost K script evaluations, K-1 of them against a half-updated input
/// set whose intermediate results nobody wants (and which the value dedup could not suppress, since each
/// intermediate value usually differs). Deliberately NOT a loop over
/// <see cref="OnDependencyChanged"/>.
/// <para>The single evaluation is stamped with the LAST item's timestamp — the newest sample in the
/// burst, and the same stamp the final evaluation of the old per-item fan-out carried.</para></summary>
private void OnDependencyBatch(DependencyValueBatch msg)
{
var items = msg.Items;
var count = items.Count;
// The mux never sends an empty batch; guard anyway rather than evaluate on no new input.
if (count == 0) return;
// Indexed for-loop, not foreach: this is the hot value ingress and an IReadOnlyList<T> enumerator
// would allocate once per burst.
for (var i = 0; i < count; i++)
{
var item = items[i];
_dependencies[item.TagId] = item.Value;
}
Evaluate(items[count - 1].TimestampUtc);
}
/// <summary>Recomputes the expression against the current dependency map and emits/dedups the result.
/// Shared by the single-value and batched dependency handlers — the only difference between them is how
/// many inputs were applied before this runs.</summary>
/// <param name="timestampUtc">The triggering dependency change's timestamp, stamped on the result.</param>
private void Evaluate(DateTime timestampUtc)
{
VirtualTagEvalResult result;
try
{
@@ -188,13 +230,13 @@ public sealed class VirtualTagActor : ReceiveActor
catch (Exception ex)
{
_log.Warning(ex, "VirtualTag {Id}: evaluator threw", _virtualTagId);
OnEvaluationFailed($"evaluator threw: {ex.Message}", "Error", msg.TimestampUtc);
OnEvaluationFailed($"evaluator threw: {ex.Message}", "Error", timestampUtc);
return;
}
if (!result.Success)
{
OnEvaluationFailed(result.Reason ?? "evaluator failure", "Warning", msg.TimestampUtc);
OnEvaluationFailed(result.Reason ?? "evaluator failure", "Warning", timestampUtc);
return;
}
@@ -219,7 +261,7 @@ public sealed class VirtualTagActor : ReceiveActor
_lastValue = result.Value;
_lastPublishedBad = false;
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair<string, object?>("outcome", "ok"));
var evalResult = new EvaluationResult(_virtualTagId, result.Value, msg.TimestampUtc, CorrelationId.NewId());
var evalResult = new EvaluationResult(_virtualTagId, result.Value, timestampUtc, CorrelationId.NewId());
Context.Parent.Tell(evalResult);
}
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
@@ -8,21 +9,9 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Services;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
[Trait("Category", "Unit")]
public sealed class LdapGroupRoleMappingServiceTests : IDisposable
public sealed class LdapGroupRoleMappingServiceTests
{
private readonly OtOpcUaConfigDbContext _db;
/// <summary>Initializes a new instance of the LdapGroupRoleMappingServiceTests class.</summary>
public LdapGroupRoleMappingServiceTests()
{
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase($"ldap-grm-{Guid.NewGuid():N}")
.Options;
_db = new OtOpcUaConfigDbContext(options);
}
/// <summary>Disposes the database context.</summary>
public void Dispose() => _db.Dispose();
private readonly RecordingContextFactory _db = new();
private LdapGroupRoleMapping Make(string group, AdminRole role, string? clusterId = null, bool? isSystemWide = null) =>
new()
@@ -165,4 +154,48 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
var byGroup = await svc.GetByGroupsAsync(["cn=sysadmins,dc=x"], CancellationToken.None);
byGroup.ShouldContain(r => r.Id == saved.Id);
}
/// <summary>
/// The service must take its context from the factory PER CALL and dispose it before returning —
/// it must never pin one context (and therefore one pooled connection) for its own lifetime. That
/// is what makes it safe on the RoleGrants Blazor page, where the DI scope is the circuit and two
/// renders can overlap: a pinned context would throw
/// "A second operation was started on this context instance" the first time they did.
/// </summary>
[Fact]
public async Task Every_call_creates_and_disposes_its_own_context()
{
var svc = new LdapGroupRoleMappingService(_db);
await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.Administrator), CancellationToken.None);
await svc.ListAllAsync(CancellationToken.None);
await svc.GetByGroupsAsync(["cn=fleet,dc=x"], CancellationToken.None);
// One context per call, and no leftovers: a service holding its own context would show 0.
_db.Created.Count.ShouldBe(3);
foreach (var ctx in _db.Created)
Should.Throw<ObjectDisposedException>(() => ctx.LdapGroupRoleMappings.ToList());
}
/// <summary>
/// Hands out a fresh <see cref="OtOpcUaConfigDbContext"/> per call over one shared in-memory store
/// (an explicit <see cref="InMemoryDatabaseRoot"/>, so every context genuinely sees the same rows),
/// and records what it created so a test can assert the per-call create/dispose discipline.
/// </summary>
private sealed class RecordingContextFactory : IDbContextFactory<OtOpcUaConfigDbContext>
{
private readonly DbContextOptions<OtOpcUaConfigDbContext> _options =
new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase($"ldap-grm-{Guid.NewGuid():N}", new InMemoryDatabaseRoot())
.Options;
public List<OtOpcUaConfigDbContext> Created { get; } = [];
public OtOpcUaConfigDbContext CreateDbContext()
{
var ctx = new OtOpcUaConfigDbContext(_options);
Created.Add(ctx);
return ctx;
}
}
}
@@ -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