perf(adminui): pooled DbContext factory; role-grants reads per-call, not per-circuit
This commit is contained in:
@@ -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<IActorRef></c> and on every AttributeValuePublished
|
||||
/// forwards a <see cref="VirtualTagActor.DependencyValueChanged"/> to each interested
|
||||
/// subscriber.
|
||||
/// keeps a map of <c>tagRef → Set<IActorRef></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);
|
||||
}
|
||||
|
||||
|
||||
+47
-14
@@ -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>
|
||||
{
|
||||
|
||||
+46
-14
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user