diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ServiceCollectionExtensions.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ServiceCollectionExtensions.cs index 658ac3af..426faf76 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ServiceCollectionExtensions.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ServiceCollectionExtensions.cs @@ -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(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(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()), which + // resolves TContext with CreateScope() + GetRequiredService() 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>().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(); return services; diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Services/LdapGroupRoleMappingService.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Services/LdapGroupRoleMappingService.cs index d09545f4..58ef8081 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Services/LdapGroupRoleMappingService.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Services/LdapGroupRoleMappingService.cs @@ -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. /// -public sealed class LdapGroupRoleMappingService(OtOpcUaConfigDbContext db) : ILdapGroupRoleMappingService +/// +/// Takes the 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 AuthEndpoints.LoginAsync; +/// 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 +/// ServiceCollectionExtensions.AddOtOpcUaConfigDb), so the per-call create is a pool rent, not +/// a fresh context build. +/// +public sealed class LdapGroupRoleMappingService(IDbContextFactory dbFactory) + : ILdapGroupRoleMappingService { /// public async Task> 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 /// public async Task> 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); + } /// public async Task 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 /// 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); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs index b6adaf5c..14fef1f2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs @@ -240,6 +240,7 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers _maxWriteAttempts = maxWriteAttempts > 0 ? maxWriteAttempts : 1; ReceiveAsync(OnValueChangedAsync); + ReceiveAsync(OnValueBatchAsync); Receive(OnUpdateHistorizedRefs); Receive(_ => OnDrainTick()); Receive(OnDrainResult); @@ -275,6 +276,30 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers base.PostStop(); } + /// + /// 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). + /// + /// ⚠️ The await below deliberately does NOT use ConfigureAwait(false). reads Self 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 NotSupportedException — 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. + /// + /// + /// The batch of matched dependency values. + private async Task OnValueBatchAsync(VirtualTagActor.DependencyValueBatch msg) + { + IReadOnlyList 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 diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs index e4a9b208..aaebb6a7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs @@ -201,6 +201,10 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor Receive(OnApply); Receive(OnAlarmsLoaded); Receive(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(OnDependencyBatch); Receive(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)); } + /// One batched mux delivery — every item already matched this host's registered interest. + /// Loops the per-value handler, preserving in-burst order. + 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; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyConsumerMuxAdapter.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyConsumerMuxAdapter.cs index a6bee640..ebe94dc0 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyConsumerMuxAdapter.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyConsumerMuxAdapter.cs @@ -45,6 +45,7 @@ public sealed class DependencyConsumerMuxAdapter : ReceiveActor _mux = mux ?? throw new ArgumentNullException(nameof(mux)); Receive(OnDependencyValueChanged); + Receive(OnDependencyValueBatch); Receive(_ => 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); } + + /// 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. + private void OnDependencyValueBatch(VirtualTagActor.DependencyValueBatch msg) + { + var items = msg.Items; + for (var i = 0; i < items.Count; i++) + { + OnDependencyValueChanged(items[i]); + } + } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs index cf06b01f..312d009e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs @@ -8,9 +8,9 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags; /// Per-node fan-out router from /// to interested instances. VirtualTagActor sends /// on start-up listing the tag refs it depends on; the mux -/// keeps a map of tagRef → Set<IActorRef> and on every AttributeValuePublished -/// forwards a to each interested -/// subscriber. +/// keeps a map of tagRef → Set<IActorRef> and on every ingress forwards each +/// interested subscriber ONE 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 /// receive stays for the comms-loss fan /// ALONE: DriverHostActor.FanCommsLossToDependencyMux fabricates that message and Tells this -/// mux directly, never through the host's own receive. Both -/// produce the same per interested subscriber. +/// mux directly, never through the host's own receive. +/// +/// The mux emits ONLY batches (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 +/// 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. /// @@ -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 })); } } - /// Filters one batched driver production burst per item — the batched analogue of - /// . 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. + /// Filters one batched driver production burst per item and delivers each subscriber's matched + /// slice as ONE . 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 evaluate its script once per burst instead of once per input. /// The registered set is NOT just virtual-tag inputs: ContinuousHistorizationRecorder /// registers interest in every historized ref, so on a historizing node can be /// large and a sizeable fraction of each burst resolves. @@ -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>? perSubscriber = null; + // Indexed for-loop, not foreach: this is the hot value ingress and an IReadOnlyList 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>(); + foreach (var sub in subscribers) + { + if (!perSubscriber.TryGetValue(sub, out var list)) + { + list = new List(); + 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)); } } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs index c57e684d..5520c282 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs @@ -37,6 +37,13 @@ public sealed class VirtualTagActor : ReceiveActor public sealed record DependencyValueChanged( string TagId, object? Value, DateTime TimestampUtc, uint StatusCode = OpcUaStatus.Good); + /// + /// 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). + /// + public sealed record DependencyValueBatch(IReadOnlyList Items); + /// Re-assert the last emitted value/quality to the parent bridge, bypassing the value dedup. /// Sent by on every apply (deploy) to a surviving child: a deploy /// re-materialises the child's published UNS node to BadWaitingForInitialData, but an @@ -132,6 +139,7 @@ public sealed class VirtualTagActor : ReceiveActor _telemetryHub = telemetryHub; Receive(OnDependencyChanged); + Receive(OnDependencyBatch); Receive(_ => OnReassertValue()); } @@ -179,7 +187,41 @@ public sealed class VirtualTagActor : ReceiveActor private void OnDependencyChanged(DependencyValueChanged msg) { _dependencies[msg.TagId] = msg.Value; + Evaluate(msg.TimestampUtc); + } + /// 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 + /// . + /// 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. + 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 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); + } + + /// 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. + /// The triggering dependency change's timestamp, stamped on the result. + 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("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); } diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/LdapGroupRoleMappingServiceTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/LdapGroupRoleMappingServiceTests.cs index c9ce8e21..54a37f41 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/LdapGroupRoleMappingServiceTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/LdapGroupRoleMappingServiceTests.cs @@ -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; - - /// Initializes a new instance of the LdapGroupRoleMappingServiceTests class. - public LdapGroupRoleMappingServiceTests() - { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase($"ldap-grm-{Guid.NewGuid():N}") - .Options; - _db = new OtOpcUaConfigDbContext(options); - } - - /// Disposes the database context. - 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); } + + /// + /// 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. + /// + [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(() => ctx.LdapGroupRoleMappings.ToList()); + } + + /// + /// Hands out a fresh per call over one shared in-memory store + /// (an explicit , so every context genuinely sees the same rows), + /// and records what it created so a test can assert the per-call create/dispose discipline. + /// + private sealed class RecordingContextFactory : IDbContextFactory + { + private readonly DbContextOptions _options = + new DbContextOptionsBuilder() + .UseInMemoryDatabase($"ldap-grm-{Guid.NewGuid():N}", new InMemoryDatabaseRoot()) + .Options; + + public List Created { get; } = []; + + public OtOpcUaConfigDbContext CreateDbContext() + { + var ctx = new OtOpcUaConfigDbContext(_options); + Created.Add(ctx); + return ctx; + } + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeTestDb.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeTestDb.cs index d0f5dd51..de6c838a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeTestDb.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeTestDb.cs @@ -139,7 +139,10 @@ internal static class UnsTreeTestDb /// /// Minimal 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 (AddPooledDbContextFactory in ServiceCollectionExtensions.AddOtOpcUaConfigDb). + /// 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. /// private sealed class NamedFactory(string name) : IDbContextFactory { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/DependencyMuxActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/DependencyMuxActorTests.cs index 0ea82630..5157f585 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/DependencyMuxActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/DependencyMuxActorTests.cs @@ -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(), - subA.ExpectMsg(), - }.OrderBy(m => m.TagId).ToList(); + subA.ExpectMsg(), + subA.ExpectMsg(), + }.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(), - subB.ExpectMsg(), - }.OrderBy(m => m.TagId).ToList(); + subB.ExpectMsg(), + subB.ExpectMsg(), + }.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(); + sub.ExpectMsg(); 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().TagId.ShouldBe("tag-2"); + sub.ExpectMsg().Items.ShouldHaveSingleItem().TagId.ShouldBe("tag-2"); } /// Verifies that a virtual tag actor registers dependencies with the mux during PreStart and that evaluation fires end-to-end. @@ -148,12 +149,13 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase new DriverInstanceActor.AttributeValueItem("ref-1", 42, OpcUaStatus.Good, DateTime.UtcNow), })); - subscriber.ExpectMsg().TagId.ShouldBe("ref-1"); + subscriber.ExpectMsg() + .Items.ShouldHaveSingleItem().TagId.ShouldBe("ref-1"); } /// 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. [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(PresenceBudget); + // Nothing for tag-y — no subscriber registered for it, so the batch carries tag-x alone. + var dep = sub.ExpectMsg(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)); + } + + /// 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 + /// DependencyValueBatch. The unmatched item never reaches the subscriber, and no second message + /// follows the batch. + [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(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().StatusCode.ShouldBe(OpcUaStatus.Bad); + sub.ExpectMsg() + .Items.ShouldHaveSingleItem().StatusCode.ShouldBe(OpcUaStatus.Bad); } private sealed class EchoSumEvaluator : ZB.MOM.WW.OtOpcUa.Commons.Engines.IVirtualTagEvaluator diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorTests.cs index b2b038f6..eaf39434 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorTests.cs @@ -205,6 +205,77 @@ public sealed class VirtualTagActorTests : RuntimeActorTestBase parent.ExpectMsg().StatusCode.ShouldBe(OpcUaStatus.BadInternalError); } + /// 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. + [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(); + 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); + } + + /// Falsification control for : the SAME + /// three values delivered as three separate DependencyValueChanged 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. + [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().Value.ShouldBe(1); + parent.ExpectMsg().Value.ShouldBe(3); + parent.ExpectMsg().Value.ShouldBe(7); + + evaluator.Calls.ShouldBe(3); + } + + /// Test evaluator that sums integer dependencies and counts how many times it was called. + private sealed class CountingSumEvaluator : IVirtualTagEvaluator + { + private int _calls; + + /// Gets the number of calls observed so far. + public int Calls => Volatile.Read(ref _calls); + + /// Counts the call and evaluates the expression by summing integer dependencies. + /// The tag identifier. + /// The expression string. + /// The dependency values. + /// The sum of integer values in the dependencies. + public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary deps) + { + Interlocked.Increment(ref _calls); + return VirtualTagEvalResult.Ok(deps.Values.OfType().Sum()); + } + } + /// Test evaluator returning a fixed sequence of results, one per Evaluate call; the last /// result repeats once the queue is drained. private sealed class QueuedEvaluator : IVirtualTagEvaluator