Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs
T
Joseph Doherty dca822dc59 perf(opcua): de-quadratic the lock-time teardown paths (B3)
Review 01 P1/P2/P3/P5 — work done under the manager-wide SDK Lock on the
pinned publish thread, where every Read/Browse/Publish waits.

- P1 RebuildAddressSpace detaches the shared root's top-level children
  once instead of every leaf from its parent (NodeState.RemoveChild is a
  linear scan + RemoveAt, ~k²/2 compares per k-child folder); root
  notifiers are cleared as a list instead of one O(F) RemoveRootNotifier
  each; ForgetPredefinedSubtree / TombstoneMonitoredSubtree are iterative
  with one child buffer.
- P2 RemoveEquipmentSubtree walks a parent→children index maintained by
  EnsureFolder/EnsureVariable/MaterialiseAlarmCondition (keyed by the
  parent id the caller named) instead of prefix-scanning six maps per
  removed equipment; a surviving condition's wiring into a removed folder
  is found through the folder's own notifier list; descendants are not
  detached from a folder that is itself being discarded.
- P3 LowerAllEventsMonitoring(condition) replaces the full O(C) reconcile
  on RemoveAlarmConditionNode / RemoveEquipmentSubtree / the kind-swap.
- P5 AlarmNotifierWiring.Folders is a reference-identity HashSet and an
  already-wired pair skips the SDK's linear AddNotifier dedup.

Benchmark (NodeManagerLockTimeBenchmarkTests, M-series Mac under load,
three runs each): rebuild of a 10k-tag folder 75–150 ms → 5–6 ms;
removing 200 equipment (250 signals each) from a 50k-node space
471–681 ms → 58–64 ms; all-events bookkeeping visits for 10 condition
removals over 50 conditions 900 → 10. The two timing legs assert < 1 s;
the P3 leg asserts the visit count (machine-independent).

Refs #732

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 10:47:31 -04:00

191 lines
10 KiB
C#

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;
/// <summary>
/// Review 01 P1 / P2 / P3 — the lock-time budget of the three teardown paths that run under the
/// manager-wide SDK <c>Lock</c> on the pinned publish thread, where every Read / Browse / Publish waits.
/// <list type="bullet">
/// <item>P1: a full rebuild of a 10k-tag device folder was O(k²) per folder because
/// <c>NodeState.RemoveChild</c> is a linear scan + <c>RemoveAt</c> per leaf (~50M compares).</item>
/// <item>P2: removing M equipment from an N-node space scanned every map per equipment (O(M·N)).</item>
/// <item>P3: every surgical condition removal re-walked every condition for the Server-object
/// monitoring bookkeeping (O(C) per removal) while any <c>i=2253</c> item existed.</item>
/// </list>
/// 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.
/// </summary>
[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}");
/// <summary>P1 — a rebuild that discards one device folder holding 10k tags completes within the budget.</summary>
[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(); }
}
/// <summary>P2 — removing 200 equipment (250 signals each, 50k nodes) completes within the budget.</summary>
[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(); }
}
/// <summary>
/// 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.)
/// </summary>
[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<OpcUaApplicationHost>.Instance),
() => new OtOpcUaSdkServer(), Ct);
public void Dispose()
{
if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true);
}
/// <summary>The minimum of a Server-object event item the all-events bookkeeping touches: identity only.</summary>
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<EventFieldList> 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();
}
}