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
This commit is contained in:
+190
@@ -0,0 +1,190 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user