using System.Diagnostics; using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Server; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// Review 01 P1 / P2 / P3 — the lock-time budget of the three teardown paths that run under the /// manager-wide SDK Lock on the pinned publish thread, where every Read / Browse / Publish waits. /// /// P1: a full rebuild of a 10k-tag device folder was O(k²) per folder because /// NodeState.RemoveChild is a linear scan + RemoveAt per leaf (~50M compares). /// P2: removing M equipment from an N-node space scanned every map per equipment (O(M·N)). /// P3: every surgical condition removal re-walked every condition for the Server-object /// monitoring bookkeeping (O(C) per removal) while any i=2253 item existed. /// /// The wall-clock budgets are generous for CI hardware; the before/after numbers are in the commit /// message. P3 is pinned by the bookkeeping's own visit counter, which does not depend on the machine. /// Kept in the default run on purpose (not a Benchmark category the CI filter would have to learn to exclude): /// measured after the fix at 5–6 ms (P1) and 58–64 ms (P2) on a loaded dev machine against the 1 s budget — /// ≥ 15× headroom, so it does not flake on slow CI. The flip side: the pre-fix code (75–150 ms / 470–680 ms) also /// fits 1 s at these sizes, so the wall-clock legs guard against gross (order-of-magnitude) regressions only; the /// machine-independent P3 visit counter is the strict pin. /// [Trait("Category", "Benchmark")] public sealed class NodeManagerLockTimeBenchmarkTests(ITestOutputHelper output) : IDisposable { private static CancellationToken Ct => TestContext.Current.CancellationToken; private static readonly TimeSpan Budget = TimeSpan.FromSeconds(1); private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-bench-{Guid.NewGuid():N}"); /// P1 — a rebuild that discards one device folder holding 10k tags completes within the budget. [Fact] public async Task Rebuild_of_a_10k_tag_folder_stays_within_budget() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; const string device = "Plant/Modbus/dev1"; nm.EnsureFolder(device, null, "dev1", AddressSpaceRealm.Raw); for (var i = 0; i < 10_000; i++) nm.EnsureVariable($"{device}/T{i}", device, $"T{i}", "Int32", writable: false, realm: AddressSpaceRealm.Raw); nm.VariableCount.ShouldBe(10_000); var sw = Stopwatch.StartNew(); nm.RebuildAddressSpace(); sw.Stop(); nm.CompleteAddressSpaceApply(); nm.VariableCount.ShouldBe(0); nm.FolderCount.ShouldBe(0); // Positive control: the space is really empty for the SDK too, and can be re-populated. nm.GetManagerHandle(new NodeId($"{device}/T0", nm.NamespaceIndexForRealm(AddressSpaceRealm.Raw))).ShouldBeNull(); nm.EnsureFolder(device, null, "dev1", AddressSpaceRealm.Raw); nm.EnsureVariable($"{device}/T0", device, "T0", "Int32", writable: false, realm: AddressSpaceRealm.Raw); nm.TryGetVariable($"{device}/T0", AddressSpaceRealm.Raw)!.Parent.ShouldBeSameAs(nm.TryGetFolder(device, AddressSpaceRealm.Raw)); output.WriteLine($"BENCH rebuild 10k-tag folder: {sw.ElapsedMilliseconds} ms"); sw.Elapsed.ShouldBeLessThan(Budget, $"rebuild of a 10k-tag folder took {sw.ElapsedMilliseconds} ms"); } finally { await host.DisposeAsync(); } } /// P2 — removing 200 equipment (250 signals each, 50k nodes) completes within the budget. [Fact] public async Task Removing_200_equipment_from_a_50k_node_space_stays_within_budget() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; const int equipmentCount = 200, perEquipment = 250; for (var e = 0; e < equipmentCount; e++) { var equip = $"area/line/eq{e}"; nm.EnsureFolder(equip, null, $"eq{e}", AddressSpaceRealm.Uns); for (var i = 0; i < perEquipment; i++) nm.EnsureVariable($"{equip}/S{i}", equip, $"S{i}", "Float", writable: false, realm: AddressSpaceRealm.Uns); } // A survivor the removals must not touch. nm.EnsureFolder("area/line/keep", null, "keep", AddressSpaceRealm.Uns); nm.EnsureVariable("area/line/keep/S0", "area/line/keep", "S0", "Float", writable: false, realm: AddressSpaceRealm.Uns); nm.VariableCount.ShouldBe(equipmentCount * perEquipment + 1); var sw = Stopwatch.StartNew(); for (var e = 0; e < equipmentCount; e++) nm.RemoveEquipmentSubtree($"area/line/eq{e}", AddressSpaceRealm.Uns).ShouldBeTrue(); sw.Stop(); nm.VariableCount.ShouldBe(1); nm.FolderCount.ShouldBe(1); nm.TryGetVariable("area/line/keep/S0").ShouldNotBeNull(); nm.GetManagerHandle(new NodeId("area/line/eq0/S0", nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns))).ShouldBeNull(); output.WriteLine($"BENCH remove 200 equipment / 50k nodes: {sw.ElapsedMilliseconds} ms"); sw.Elapsed.ShouldBeLessThan(Budget, $"removing 200 equipment took {sw.ElapsedMilliseconds} ms"); } finally { await host.DisposeAsync(); } } /// /// P3 — with a Server-object item live, a surgical condition removal lowers only that condition's /// monitoring unit instead of re-walking every condition: the bookkeeping visits at most one /// condition per removal. (A full reconcile visits every condition twice — desired + held.) /// [Fact] public async Task A_surgical_condition_removal_visits_only_its_own_condition() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; nm.EnsureFolder("eq-1", null, "Equipment 1", AddressSpaceRealm.Uns); const int conditions = 50; for (var i = 0; i < conditions; i++) nm.MaterialiseAlarmCondition($"alm-{i}", "eq-1", $"Alarm {i}", "OffNormalAlarm", 500, AddressSpaceRealm.Uns); // A Server-object (all-events) subscriber, so the per-condition monitoring unit is in play. nm.SubscribeToAllEvents( new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.CreateMonitoredItems, identity: null), 1, new StubEventItem(), unsubscribe: false); nm.ConditionEventsMonitored("alm-0", AddressSpaceRealm.Uns).ShouldBeTrue("non-vacuity: the units really were raised"); var before = nm.AllEventsMonitoringConditionVisits; for (var i = 0; i < 10; i++) nm.RemoveAlarmConditionNode($"alm-{i}", AddressSpaceRealm.Uns).ShouldBeTrue(); var visits = nm.AllEventsMonitoringConditionVisits - before; output.WriteLine($"BENCH all-events visits for 10 condition removals over {conditions} conditions: {visits}"); visits.ShouldBeLessThanOrEqualTo(10, $"ten removals visited {visits} conditions; a full reconcile per removal would visit ~{10 * 2 * conditions}"); // The survivors keep their unit. nm.ConditionEventsMonitored("alm-49", AddressSpaceRealm.Uns).ShouldBeTrue(); } finally { await host.DisposeAsync(); } } private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() => await OpcUaHostStart.StartAsync( port => new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.BenchTest", ApplicationUri = $"urn:OtOpcUa.BenchTest:{Guid.NewGuid():N}", OpcUaPort = port, PublicHostname = "localhost", PkiStoreRoot = _pkiRoot, }, NullLogger.Instance), () => new OtOpcUaSdkServer(), Ct); public void Dispose() { if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true); } /// The minimum of a Server-object event item the all-events bookkeeping touches: identity only. private sealed class StubEventItem : IEventMonitoredItem { public bool MonitoringAllEvents => true; public NodeId NodeId => ObjectIds.Server; public IUserIdentity EffectiveIdentity => null!; public object ManagerHandle => null!; public void QueueEvent(IFilterTarget instance) { } public void QueueEvent(IFilterTarget instance, bool bypassFilter) { } public void Dispose() { } public INodeManager NodeManager => throw new NotSupportedException(); public ISession Session => throw new NotSupportedException(); public uint Id => 1; public uint SubscriptionId => 1; public bool IsDurable => false; public uint ClientHandle => 1; public ISubscription SubscriptionCallback { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } public int MonitoredItemType => MonitoredItemTypeMask.Events; public bool IsReadyToPublish => false; public bool IsReadyToTrigger { get => false; set { } } public bool IsResendData => false; public MonitoringMode MonitoringMode => MonitoringMode.Reporting; public double SamplingInterval => 0; public EventFilter EventFilter => null!; public void SetupResendDataTrigger() { } public ServiceResult GetCreateResult(out MonitoredItemCreateResult result) => throw new NotSupportedException(); public ServiceResult GetModifyResult(out MonitoredItemModifyResult result) => throw new NotSupportedException(); public IStoredMonitoredItem ToStorableMonitoredItem() => throw new NotSupportedException(); public MonitoringMode SetMonitoringMode(MonitoringMode monitoringMode) => throw new NotSupportedException(); public bool Publish(OperationContext context, Queue notifications, uint maxNotificationsPerPublish) => throw new NotSupportedException(); public ServiceResult ModifyAttributes(DiagnosticsMasks diagnosticsMasks, TimestampsToReturn timestampsToReturn, uint clientHandle, MonitoringFilter originalFilter, MonitoringFilter filterToUse, Opc.Ua.Range range, double samplingInterval, uint queueSize, bool discardOldest) => throw new NotSupportedException(); } }